Compare commits

...

24 Commits

Author SHA1 Message Date
Aditya Singh
63d0470894 refactor(quick-filters): one quick filter source drives the saved list and the fields api signal (#12832)
Some checks are pending
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
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description
- Quick filters took two props for the same page: `source` for the page
and `signal` for the saved filter list. Every page passed a matching
pair.
- Quick filters now take one `quickFilterSource` prop. Its values are
the backend quick filter sources, plus `INFRA_MONITORING`, which has no
saved list.
  - The saved filter list request uses the prop value directly.
- One map gives the fields API signal for each source. It replaces
`SIGNAL_DATA_SOURCE_MAP` and the per-filter `dataSource`, so
`dataSource` is removed from the filter configs.
- Requests stay the same on every page, with one exception. The infra
Environment filter had no `dataSource`, so it sent no signal and listed
environments from traces and logs too. It now sends `metrics`, so it
lists only environments that the infra tables can show.

#### Issues closed by this PR

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

#### Additional Information
- Three QuickFilters stories (`NoResults`, `SelectedExpandedCheckbox`,
`Tooltips`) now show the settings control, because the logs page always
has a saved filter list.
2026-10-27 18:34:10 +00:00
Ashwin Bhatkal
c69b77e030 feat(alerts): move anomaly alerts to the new alert experience (#13130)
#### Description

- New anomaly alerts are created in the new alert form and saved as
`schemaVersion: v2alpha1`. The backend already accepts and evaluates
anomaly rules in this shape; only the frontend routing kept them on the
classic form.
- Anomaly alerts are offered only on cloud and enterprise self-hosted
licenses. This replaces the `anomaly_detection` feature check, which the
backend never enforced.
- The anomaly condition reads like the threshold one: a sentence with
the query, direction, match type and window, then the algorithm and
seasonality, then one row per severity with its deviation and channels,
as on threshold alerts. The deviation is a number above 0, so values
like 2.5 work, and ON AVERAGE and LAST join the match types.
- Thresholds are literal z-scores: "below N deviations" is sent as
`target: -N` with an empty `targetUnit`, since a unit would rescale the
z-score. The window uses the threshold window picker, locked to rolling.
- The `anomaly` query function carries the deviation and seasonality, so
the preview band matches what the rule evaluates. Only builder metric
queries are offered.
- Switching between Threshold and Anomaly keeps the query, and the tab
tooltips say what each choice saves. Saved anomaly rules reopen on the
Anomaly tab with their settings.
- Fixed for every alert type: thresholds typed into a new alert were
reset each time the query ran, because the form re-applied its URL
prefill on any URL change. It now re-applies only when a prefill param
changes.

#### Screenshots / Screen Recordings

[Before and after
screenshots](https://claude.ai/artifact/UzhpfdaHJrFePTDAZLAtwD) of every
story under `Pages/Alerts/Create` and `Pages/Alerts/Overview`, in dark
and light: 44 pairs, 8 new screens, 26 changed (24 of them only gain the
Anomaly tab on metric alerts), 10 pixel-identical.

#### Additional Information

- Existing v1 anomaly rules still open in the classic editor, the same
as other v1 rules.
- `anomaly_detection` is still served by the backend plans and listed in
the frontend feature keys and mocks. Nothing reads it any more; removing
it is a separate cleanup.
2026-10-27 18:17:01 +00:00
Abhi kumar
4daf425a19 test(dashboards): e2e for viewing, sections, time range, variables and settings (#13264)
#### Description

Part of the dashboards E2E stack; base #12410. Ports the old dashboard
detail specs onto the drivers.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- `drivers/dashboard-page.ts`: breadcrumb, title, tags, toolbar, Actions
menu, inline rename, lock / unlock, full screen, JSON drawer, sections.
- `drivers/dashboard-settings.ts`: time picker, variables bar (every
variable type), settings drawer (overview, variables, publish).
- `page/viewing`, `page/edge-cases`, `page/sections`, `page/edit-mode`,
`page/time-range`, `page/variables`, `page/settings` (67 tests).
- Also covers cases the old specs only held as `test.skip` placeholders:
Edit panel opening the editor, cancelling a panel delete, a variable
change refetching its panel, the variable description tooltip,
drag-reordering variables and creating a dynamic variable in Configure.

#### Additional Information

- Two product bugs are pinned as `test.fixme`:
- A share link's `?variables=` is ignored on a first visit
(SigNoz/pulse-pod#621).
- A changed textbox default never reaches anyone who opened the
dashboard before (SigNoz/pulse-pod#622).
- Adapted to the current product: collapse is a per-user preference that
survives a reload (no save), Export / Copy JSON live in the JSON drawer,
rename is inline, lock disables rather than hides, names are capped at
128 characters, and a bad dashboard id shows "Failed to load dashboard".
2026-10-27 18:12:58 +00:00
Abhi kumar
6f2ee41dcb test(dashboards): e2e smoke checks for every panel kind (#13273)
#### Description

Part of the dashboards E2E stack; base #12410.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- One thin spec per panel kind (Bar, Area, Histogram, Heatmap, Pie,
Number, Table, List): it renders, its actions menu offers exactly its
download formats (and Create Alerts where it applies), and a click opens
drilldown or not as the kind declares; a List row opens the log detail
drawer instead. 2 tests each.
- Text keeps its authoring flow: the picker opens the Markdown editor,
and Markdown previews, saves with no query and renders on the grid.
Markdown rendering itself is left to `MarkdownContent.test.tsx` and the
Text story.
- Per review, the per-kind chart and config detail lives in the panel
stories (#13244) and jest, not e2e; the one full panel flow is Time
Series in #13233.
- One commit per kind.
2026-10-27 18:03:50 +00:00
Abhi kumar
8ab8c63bea test(dashboards): e2e for the dashboards list page (#13263)
#### Description

Part of the dashboards E2E stack; base #12410. Ports the old dashboards
list spec onto the drivers.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- `list/search`, `list/sort`, `list/row-actions`, `list/create-import`,
`list/navigation` (24 tests): search by title, tag and URL query
(case-insensitive via `ILIKE`), every sort and sort params on load, the
row menu (open in new tab, copy link, cancel delete), the New dashboard
dialog and its default name, importing an uploaded JSON file, row click,
side-nav link, Back restoring a typed search.
- `drivers/dashboard-list.ts` grows search, sort, row-menu, dialog and
import handles.

#### Additional Information

Not ported, because the behaviour is gone: Export JSON in the row menu
(removed), and the old "Cancel delete opens the dashboard" bug, which is
fixed — the port asserts the fixed behaviour. Delete-with-confirm lives
in `page/from-scratch` (#12410).
2026-10-27 17:59:01 +00:00
Abhi kumar
0513ab7f1f test(dashboards): e2e flow for the Time Series panel (#13233)
#### Description

Part of the dashboards E2E stack; base #12410.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- The one full panel flow: create a Time Series panel from the picker,
pick a metric, run, set an axis bound, a right legend and a label
threshold, save, then check the grid and the saved spec, and again after
a reload.
- Panel time preference: the header pill reads the chosen range.
- The one-row-at-a-time threshold editing is a jest case in
`ThresholdsSection.test.tsx`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/4515
2026-10-27 17:50:31 +00:00
Aditya Singh
83b4754eaa fix(query-builder): explorer crash on unsupported query in url (#13257)
#### Description

- Agent-built explorer links often carry a `compositeQuery` that the
page cannot run. Examples are a ClickHouse query on the logs explorer, a
missing `builder` block, and the wrong signal. These links crashed the
page or ran the wrong query.
- `normalizeCompositeQuery` repairs the url query before the query
builder seeds its state. Missing blocks get their defaults, and every
caller of `useGetCompositeQueryParam` reads the same repaired query.
- Each explorer route declares what it can run in
`explorerQueryRules.ts`: `dataSource`, `source`, `builderQueryType` and
the trace operator. Explorers always run the query builder. A query that
does not match resets to the explorer default and keeps its name.
- When normalization changes the query, the provider rewrites only the
`compositeQuery` param, with replace. Other url params such as
`activeLogId` stay. Before, a redirect with push trapped the Back button
and dropped `activeLogId`. A valid url stays untouched.

#### Issues closed by this PR

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

#### Screenshots/Screen recordings

**1. Builder-less ClickHouse link crashes the logs explorer**

Before:
[link](https://app.us.staging.signoz.cloud/logs/logs-explorer?compositeQuery=%7B%22queryType%22%3A%22clickhouse_sql%22%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22id%22%3A%2260ee651b-ea5b-4241-bf1c-59785109d75a%22%2C%22unit%22%3A%22%22%7D)



https://github.com/user-attachments/assets/488db013-0503-4ec6-91b4-6699fa96a9b2

After



https://github.com/user-attachments/assets/7b1f82b7-0653-4c60-b4ab-78f29e030026


**ClickHouse SQL runs on the logs explorer**

Before:
[link](https://app.us.staging.signoz.cloud/logs/logs-explorer?compositeQuery=%7B%22queryType%22%3A%22clickhouse_sql%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22----%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22type%22%3A%22%22%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22filter%22%3A%7B%22expression%22%3A%22%22%7D%2C%22aggregations%22%3A%5B%7B%22expression%22%3A%22count%28%29%20%22%7D%5D%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3Anull%2C%22having%22%3A%7B%22expression%22%3A%22%22%7D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%7D%5D%2C%22queryFormulas%22%3A%5B%5D%2C%22queryTraceOperator%22%3A%5B%5D%7D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22SELECT%201%22%7D%5D%2C%22id%22%3A%2260ee651b-ea5b-4241-bf1c-59785109d75a%22%2C%22unit%22%3A%22%22%7D)

<img width="3456" height="2010" alt="image"
src="https://github.com/user-attachments/assets/601e60ed-2443-4007-9f20-7ba427258a43"
/>

After


https://github.com/user-attachments/assets/5fa97237-c5d0-4297-b0ad-57fc4459fe09




#### Additional Information

- Scalar values such as `limit`, `orderBy` and `groupBy` are out of
scope. The main goal here stays to protect against wrong urls generated
via agentic workflows as users dont manually write these up and agents
follow the proper structure for these scaler vals. Its validation can be
later added if needed.
- `useGetCompositeQueryParam.ts` holds two hooks, so the diff of the
original hook stays small.
- The comment at `utils/explorerUtils.ts:136` describes the removed
redirect and is now stale.
2026-10-27 16:35:09 +00:00
Abhi kumar
120da85b49 test(dashboards): e2e for panel and editor flows (#12410)
#### Description

Part of the dashboards E2E stack; base #12409.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- `page/*`: panel data states (no data, error + Retry, loading — once,
on Time Series), extend time range, panel actions incl. CSV download,
grid layout, zoom, drilldown, View modal, creating a dashboard from the
list page.
- `editor/*`: New Panel picker, editor shell, query pane, type
switching, the disabled-kind tooltip, a context link saved in the editor
and shown in the drilldown menu.
- Cases already covered by jest are left to it: picker search and
categories, the context-link dialog, the panel header, NoData actions,
Save gating and the locked actions menu.

#### Issues closed by this PR

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

#### Additional Information

- `view-modal` does not cover "browser Back closes the modal": the modal
rewrites `compositeQuery` into the URL with a fresh id, so Back never
leaves it. Browser Back also doesn't leave the panel editor for the same
reason, so `editor/shell` TC-06 changes route client-side instead
(SigNoz/pulse-pod#623).
2026-10-27 16:22:50 +00:00
Abhi kumar
b57e5e8e09 test(dashboards): e2e drivers and spec builders (#12409)
#### Description

Base of the dashboards E2E stack.

| PR | Content | Base | Tests |
|---|---|---|---:|
| #13271 | uPlot instance on the chart container | `main` | — |
| #12409 | drivers, spec builders; drops the old dashboard specs |
#13271 | — |
| #12410 | panel and editor flows | #12409 | 74 |
| #13263 | dashboards list page | #12410 | 24 |
| #13264 | viewing, sections, edit mode, time range, variables, settings
| #12410 | 67 |
| #13233 | Time Series: the one full panel flow | #12410 | 2 |
| #13273 | thin checks for every other panel kind | #12410 | 18 |

Merge #13271, #12409, #12410; the rest are independent of each other.

- Driver-based harness (`docs/contributing/tests/e2e.md` → Drivers):
`dashboards` seeds over `POST /api/v2/dashboards` and cleans up
(unlocking first, so a test that locks never leaks its dashboard);
`panels`, `panel-editor`, `panel-config` and `dashboard-list` drive the
UI. `driver()` gains an optional `dispose`.
- Typed spec builders (`helpers/dashboard-spec.ts`, types in
`dashboard-spec.types.ts`) and `query_range` mocks
(`helpers/query-range-mock.ts`).
- Deletes the specs written for the old dashboard
(`tests/dashboards/list.spec.ts`, `details/**`), which target the
rejected `/api/v1/dashboards` and were skipped on `main`, with their
`helpers/dashboards.ts` and testdata JSONs; `routing` now seeds through
the driver. Every behaviour is ported in #13263 and #13264; the few the
product no longer has are listed there.
- Test ids for the panel header and preview pane.

#### Issues closed by this PR

Part of SigNoz/engineering-pod#4304.

#### Additional Information

- `pnpm typecheck` in `tests/e2e` is broken on `main` (`module:
commonjs` + `moduleResolution: bundler`, TS5095); this stack is
typechecked with `--module esnext --lib ES2020,DOM`. Fixing the config
is its own PR.
2026-10-27 16:22:49 +00:00
Abhi kumar
ba155067e7 test(dashboards): expose the uPlot instance on the chart container (#13271)
#### Description

- `UPlotChart` hangs its live uPlot instance on the chart container
(`__uplot`), set on create and removed on destroy. No render path or
uPlot option changes.
- A chart is a canvas, so tests have nothing else to read series, scales
or bands from. The dashboards e2e stack (#12409) and the panel stories
(#13244) both build on this.
2026-10-27 16:22:48 +00:00
Aditya Singh
070343a6c0 feat(saved-views): saved views and quick filters VQA fixes (#13232)
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
- The view name in the saved views header now opens and closes the views
panel.
- It works only when the All views button works: the user can list
views, the view has no unsaved changes, and the user can read the open
view.
  - An open panel can always be closed from the name.
- A click anywhere outside the sidebar closes the views panel. Clicks in
the menus, dialogs and hover card that the sidebar opens do not close
it.
- In quick filter sections, the search icon is gone.
- A search box shows only when a section has more than 10 values, the
same limit as "Show more".
  - Reset shows only when the section has a filter applied.

#### Issues closed by this PR

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




https://github.com/user-attachments/assets/634d8f71-313f-481e-b58a-144e3908a540
2026-10-27 09:56:19 +00:00
Srikanth Chekuri
19af7782d9 chore(openapi): describe the API payload fields and add request examples (#13200)
#### Description

- The OpenAPI spec carried almost no field descriptions: 5 property
descriptions across 671 schemas before this work. Clients that generate
from it, the MCP server's tool schemas among them, got names and types
but no meaning, format, defaults or dependencies.
- Adds `description` struct tags to every JSON-visible field of the
payloads the API exposes for rules, the query builder v5, field lookups,
metric types, notification channels, saved views, dashboards v2 and the
metrics explorer: 781 descriptions in all (ruletypes 94,
querybuildertypesv5 154, telemetrytypes 41, metrictypes 2,
alertmanagertypes 164, savedviewtypes 26, dashboardtypes 234,
metricsexplorertypes 66). Shared schemas such as `Filter` and
`TelemetryFieldKey` stay signal-neutral; the fields that reference them
carry the signal-specific examples, which #13194 made possible.
- Adds request examples to the registrations that had none: notification
channel create (one per kind), update and test (14), saved view create
and update (5), dashboard create, update and patch (5), metrics treemap
(2). The spec advertises these as payloads the API accepts, so the
`*_examples_test.go` tests decode each one through the same
`UnmarshalJSON` and `Validate` path the handlers run.
- Encodes the constraints the validators enforce where the schema can
carry them: `schemaVersion` pattern, `maxItems` on tags, `maxLength` on
display names.
- Descriptions state the contract at the API boundary, not the
implementation: meaning, unit, precision, defaults, dependencies and
what the caller can observe. Behavior that is accepted but not applied
(`limitBy`, formula `having`, `secondaryAggregations`, several dashboard
fields the UI does not read) is described as such rather than promised.
Scan statistics are described as a cost indicator only.
- Regenerates `docs/api/openapi.yml` and the frontend API clients. The
spec changes are descriptions, examples and the three constraints;
discriminators, required lists and types are unchanged, and the
generated TypeScript types are unchanged apart from JSDoc.

This squashes the reviewed per-package PRs #13195, #13196, #13197,
#13198, #13199, #13201 and #13202 into one change; their review threads
hold the per-finding verification.

#### Issues closed by this PR

Part of SigNoz/nerve-pod#426. Builds on #13194.

Follow-ups filed from the review: SigNoz/pulse-pod#604 (bare `A.0`
aggregation references in formulas never worked), SigNoz/nerve-pod#498
(units registry and `GET /api/v1/units`), SigNoz/nerve-pod#499 (listing
the bundled dashboard icons and logos).

#### Additional Information

Verified with `go build ./...`, the type, apiserver and signoz package
tests, `go run cmd/enterprise/*.go generate openapi` and `cd frontend &&
pnpm generate:api` on every commit. Suggest squash on merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 08:35:12 +00:00
Gaurav Tewari
119ab97240 feat: llm o11y improvements (#13229)
#### Description

- **Thread "Go back" stays on the trace:** switching tabs now adds a
browser history entry, so going back from Thread returns to Overview
instead of leaving the trace page.
- **Learn more link:** the Thread empty state now points to
`/docs/ai-observability/` instead of `/docs/llm-observability/`.
- **Empty state UI:** the "no AI spans" Thread state is centered, with
title/description typography matching the "Span not found" state. (In
design it is still left, but let's move it to center it's better.)
- **Span not found only on 404:** an empty thread (span exists, no AI
spans) now shows the no-AI-spans state instead. otherwise user have
click back 2 time :( which is not right .
- **Remove Scroll & key update**: So after feedback from dev we found
scroll in accordian does not feel right. we are removing it. also
"input" key we are using to get json view might be empty sometime. so we
are using formatted onces for both input and output now.
- **Service Dot**: added little margin 

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

now - 
<img width="1722" height="1108" alt="image"
src="https://github.com/user-attachments/assets/c8fc6319-f2b8-49e9-80c9-5383616ad5a7"
/>
prev - 

<img width="1710" height="1113" alt="image"
src="https://github.com/user-attachments/assets/d1c13e95-c1a1-4fdb-aebd-ebbe69c286ba"
/>


span details - 
prev- 
<img width="912" height="997" alt="image"
src="https://github.com/user-attachments/assets/d94a4a11-5ab3-4998-b5eb-76375f013115"
/>

now - 
<img width="939" height="1013" alt="image"
src="https://github.com/user-attachments/assets/69224b76-c5f5-4937-8e2d-ff7a1fb5ed78"
/>


service dot- 
before- 
<img width="335" height="81" alt="image"
src="https://github.com/user-attachments/assets/21ad13cb-1050-45e4-82f7-bcdef002d31e"
/>

now - 
<img width="371" height="91" alt="image"
src="https://github.com/user-attachments/assets/18143a71-267b-4923-82ab-eaef1aa94bfa"
/>

#### Additional Information

- Browser back and the header back arrow now also step through tab
switches (Overview → Thread → back lands on Overview).
- The empty-state title is reworded to "This trace has no spans from
AI-related actions".

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-27 08:12:17 +00:00
Aditya Singh
e163ec3b1b feat(saved-views): analytics for saved views and the bottom strip (#13164)
#### Description
- Saved views actions now send analytics events: View created, View
updated, View details edited, View deleted, Link copied, View cleared
and Changes discarded.
- Every event carries `source` (logs, traces, metrics or meter), and
`viewId` when there is a view.
- View created also carries `from`: `header`, `save_as_new` or
`empty_state`.
  - An event fires only after the action succeeds.
- The events follow the trace details pattern: an `events.ts` file and a
`useSavedViewLogEvent` hook that adds `source`.
- Ask Noz in the bottom strip now logs `AI Assistant: Opened` with
`source: bottom_strip`. Strip opens now show in the same funnel as the
header and shortcut opens.
- Support in the bottom strip logs `Bottom Strip: Support clicked`, with
`type` set to `pylon` or `add_card`.

#### Issues closed by this PR

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

#### Additional Information
- The saved views events use new names. The old per-explorer events,
such as `Logs Explorer: Save view clicked`, stop when #13125 turns on
the flag.
- Opening a view, opening the views panel and searching are not tracked.
- The strip has no conversation history, so #58 item 1 covers Support
only.
2026-10-27 05:45:51 +00:00
Aditya Singh
1e89b49ecf chore(quick-filters): remove the old checkbox filter (#13262)
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
- All quick filter pages use `CheckboxV2` and the fields APIs. This PR
removes the old checkbox filter and the code only it used:
- the old `Checkbox` component and its
`/v3/autocomplete/attribute_values` fetch
- the `useGetQueryKeyValueSuggestions` hook and the deprecated
`getValueSuggestions` client
- `EmptyQuickFilterIcon` and an Infra Monitoring style for the old
checkbox
- `useFieldApis` is now required on `QuickFilters`. Every page already
passes it, so users see no change.
- The deleted checkbox test had the only tests for the auto-open of a
filter with an active clause. These tests now run against
`CheckboxFilterV2`.

#### Issues closed by this PR

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

#### Additional Information
- The "Opt-in" doc comment on the `useFieldApis` type is removed. It
said the fields API was optional, which is no longer true.
- `getValueSuggestions` was part of
https://github.com/SigNoz/engineering-pod/issues/5289.
- The prop renames from the same ticket follow in #12832, which will be
rebased on this PR.
2026-10-26 18:07:29 +00:00
Vinicius Lourenço
8a3702a7f9 chore(deps): bump handlebars,brace-expansion,storybook (#13267)
<!--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 deps to address the reports by Vanta.

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

Closes https://github.com/SigNoz/uforge-pod/issues/6388
2026-10-26 17:44:36 +00:00
Vikrant Gupta
0376eec24f fix(serviceaccount): delete role mappings when a service account is deleted (#13266)
#### Description

- A soft-deleted service account kept its `service_account_role` rows,
so any role assigned to it could never be deleted: the assignee check
reported `role_has_service_account_assignees`, and the `role_id →
role(id)` foreign key would block the role row's deletion regardless.
- On service account delete, the role mappings are now removed inside
the existing transaction. OpenFGA tuples were already revoked there, so
authz behavior is unchanged.
- A migration cleans up `service_account_role` rows left behind by
already-deleted service accounts, mirroring
`115_delete_orphan_user_roles`.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#179
2026-10-26 16:36:54 +00:00
Naman Verma
368efd2afe fix: fix time series caching edge cases (#12813)
#### Description

- Builder queries: a cached read returns only values whose whole step
lies inside the requested window. The step the window ends inside is
always fetched live, so a cached reload no longer shows a point that
aggregates time past the window end.
- PromQL: the value at the window end is an instant, not a span, so
cached reads keep it. Every cached reload was losing the last point.
- PromQL: start and end are rounded down to the step before evaluation
(what Cortex does with `align_queries_with_step`), so windows a few
seconds apart ask for the same instants and share one cache entry.
Without this each window evaluated on its own grid and a cached read
could hand one window another window's points.
- `skipStepAlignment: true` on a promql query keeps the window exactly
as given and bypasses the cache, for callers that need Prometheus-exact
timestamps. The PromQL conformance tests use it.
- Heatmap: a cached builder heatmap trims its bucket axis to the buckets
the returned columns count in, so it matches the uncached answer for the
same window. PromQL heatmap points are never partial, so they are now
cached instead of being dropped at the window edges.

#### Issues closed by this PR

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

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-26 16:17:38 +00:00
Vikrant Gupta
fe9165447f chore(skills): add authz-configurator skill (#13261)
#### Description

- The skill walks the end-to-end authz procedure from
`docs/contributing/go/authz.md` — registering kinds/resources, granting
managed-role permissions, wiring routes with `ResourceDef`s, backfilling
tuples, and writing the authz integration tests.
2026-10-26 15:50:33 +00:00
Swapnil Nakade
aaca29d26d feat: adding gcp cloud integration agent mode changes (#13103)
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

- Adding gcp cloud integration agent mode support
  - Type changes
  - Module changes
  - Generated openapi specs
- Added a new key ConnectionMethod for GCP provider to differentiate
between Agent and Manual flow
- Added integration tests for agent mode
- Frontend changes will be addressed in different PR

<!--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/148

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-26 09:39:25 +00:00
ashu8912
2139580419 fix(settings): remove notification channels from settings nav (#13225)
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

- Removed the Notification Channels entry from the settings side nav;
the channel screens are still reachable at their own route.
- Renamed the `shift+g+n` shortcut from
`NavigateToSettingsNotificationChannels` to
`NavigateToNotificationChannels`, since it no longer points into
Settings. The key binding and target route are unchanged.
- Updated the Settings nav test so it checks that the entry is gone.

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

#### Additional Information

Resolves the TODO linked to SigNoz/engineering-pod#5323.
2026-10-26 01:43:18 +00:00
Vinicius Lourenço
9ba2a25eb9 refactor(workspaced-locked): avoid using images from signoz.io (#12880)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (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
<!--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

This prevents issues similar to
https://github.com/SigNoz/engineering-pod/issues/6083

I didn't find any other image being imported from signoz.io

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

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

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

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

Before (when the images were removed from signoz.io):

<img width="809" height="872" alt="image"
src="https://github.com/user-attachments/assets/4a30aad9-cc46-457c-af88-88758db79c5f"
/>

After:

<img width="970" height="980" alt="image"
src="https://github.com/user-attachments/assets/328b17a9-bcbf-44d3-90df-f5f71beb53a7"
/>
2026-10-23 15:35:31 +00:00
Swapnil Nakade
30839d390f chore: bump cloud integration agent version (#13245)
#### Description

- bump cloud integration agent version to v0.0.16


#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/161
2026-10-23 12:10:52 +00:00
Aditya Singh
2483c41452 chore(codeowners): add owner for saved views and the bottom strip (#13243)
#### Description
- Adds @aks07 as code owner for the saved views and bottom strip code:
  - `frontend/src/container/SavedViews/`
  - `frontend/src/container/Home/SavedViews/`
  - `frontend/src/hooks/saveViews/`
  - `frontend/src/container/BottomStrip/`
2026-10-23 11:47:34 +00:00
414 changed files with 24909 additions and 13667 deletions

View File

@@ -0,0 +1,142 @@
---
name: authz-configurator
description: Add authorization (authz) to a SigNoz backend feature end-to-end — register kinds/resources, grant managed-role permissions, wire routes with ResourceDefs, backfill tuples for existing orgs, and write the authz integration tests. Use this whenever the user mentions authz, authorization, permissions, access control, managed roles (admin/editor/viewer), OpenFGA, ResourceDef, CheckResources, 403/forbidden behavior on an endpoint, wiring or protecting a new route, registering a new resource/kind, or writing integration tests that assert role-based allow/deny. Also use it when a new API endpoint is being added and its access control hasn't been decided yet — every route needs an authz decision even if the user didn't say the word.
---
# SigNoz Authz Workflow
This skill automates the procedure documented in `docs/contributing/go/authz.md`. Read that doc first — it is the canonical explanation of the concepts (types, verbs, kinds, resources, selectors, tuples, the community/enterprise split). This skill tells you what to do; the doc tells you why it works.
The core model in one paragraph: permissions are never attached to users. They are attached to **roles** as OpenFGA tuples, and users/service accounts are made **assignees** of roles. You declare everything in code registries under `pkg/types/coretypes/`; tuples for new organizations are derived from those registries at org bootstrap. Routes declare what resource+verb they touch via `ResourceDef`s, and middleware turns that into a check that works in both community (role gate) and enterprise (per-resource FGA) editions.
## Before you start
Figure out what shape the work is. Ask the user if unclear:
- **New resource** (new kind + routes) → all steps below.
- **New route on an existing resource** → step 4 only (maybe step 3 if roles need new verbs).
- **Changing what a role can do** → steps 3, 5, 6, 7.
- **Just tests** → steps 6, 7.
Almost every feature resource is a `metaresource` (dashboards, rules, pipelines) or a `telemetryresource` (logs, traces, metrics). If the feature genuinely needs a brand-new FGA **type**, stop and flag it to the user — that requires editing both `pkg/authz/openfgaschema/base.fga` and `ee/authz/openfgaschema/base.fga` together and is almost never the right call. A new **kind** never touches the schema.
## Step 1: Register the kind
Edit `pkg/types/coretypes/registry_kind.go`. Add a var and append it to the `Kinds` slice (both are required — the slice is what gets validated and iterated):
```go
KindThing = MustNewKind("thing")
```
Kind names are lowercase, hyphen-separated (`quick-filter`, `trace-funnel`, `notification-channel`).
Also add the kind to `Kind.Enum()` in `pkg/types/coretypes/kind.go` — a second hand-maintained list mirroring `Kinds` that feeds the OpenAPI enum. It is easy to miss because nothing fails at build time without it; the drift only shows up in the generated spec.
## Step 2: Register the resource
Edit `pkg/types/coretypes/registry_resource.go`. Add a var and append it to the `Resources` slice:
```go
ResourceMetaResourceThing = NewResourceMetaResource(KindThing)
```
Use `NewResourceTelemetryResource(KindThing)` for telemetry data resources. Pass an explicit verb list only when the resource supports fewer verbs than the type default — see `ResourceMetaResourceFactorAPIKey` in that file for the shape.
## Step 3: Grant permissions to managed roles
Edit `pkg/types/coretypes/registry_managed_role.go`. Add `Transaction` entries to `ManagedRoleToTransactions` for each role that may act on the resource:
```go
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindThing}, WildCardSelectorString)},
```
The convention: **admin** gets everything; **editor** gets full CRUD only on day-to-day observability resources (dashboards, rules, saved views — not access control or org settings); **viewer** gets `read`/`list`; **anonymous** gets nothing (public dashboards are the only exception). Match the file's existing style, including its one-line grouping comment per resource block (e.g. `// thing — editors manage, viewers read`) — that file uses grouping comments as structure; do not add comments anywhere else.
This map is the single source of truth: changing it affects only organizations created afterwards (see step 5 for existing orgs), and the integration suite asserts its contents via a golden file (see step 6).
## Step 4: Wire the route
Register routes in the existing domain file under `pkg/apiserver/signozapiserver/` — do not create new per-sub-resource route files. Each route needs three coordinated pieces:
1. **`provider.authzMiddleware.CheckResources(handlerFn, roles...)`** — the role list is the community-edition fallback gate (which managed roles may call this route when per-resource checks are unavailable).
2. **`handler.WithResourceDefs(...)`** — declares resource, verb, audit category, ID extractor, and selector.
3. **`SecuritySchemes: newScopedSecuritySchemes([]string{resource.Scope(verb)})`** — advertises the scope in the OpenAPI spec.
Selector rules (getting these wrong silently over- or under-authorizes):
| Operation | Selector | ID extractor |
|---|---|---|
| create | `WildcardSelector` | `ResponseJSONPath("data.id")` (ID exists only after the handler runs; recorded for audit) |
| list | `WildcardSelector` | none |
| read/update/delete of one instance | `IDSelector` | `PathParam("id")` or `BodyJSONPath(...)` |
| request ID ≠ FGA selector (e.g. route has a role UUID, FGA objects use role names) | custom `SelectorFunc` | as appropriate |
Routes that link two resources check **both** sides. Read `references/route-wiring.md` before wiring any create route, linking route (attach/detach, parent-child), or custom selector — it has complete real examples from `serviceaccount.go`.
Prefer `CheckResources` + `ResourceDef` for anything resource-shaped. The legacy gates `ViewAccess`/`EditAccess`/`AdminAccess` are coarse role-only checks — do not use them for new routes. `OpenAccess` means no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes.
## Step 5: Backfill existing organizations (only if needed)
New organizations derive their tuples from the registries at creation — no migration needed for them. If **existing** organizations must get the new permissions, add a migration in `pkg/sqlmigration/` that does two things per org:
1. **Insert the FGA tuples** — follow `083_add_role_crud_tuples.go`: one `tuple` row (+ `changelog` row) per role/verb/object, with `ON CONFLICT ... DO NOTHING` so it stays idempotent, handling both the Postgres and SQLite tuple column layouts as that file does.
2. **Refresh the managed roles' JSON record** — since `099_add_role_transaction_groups.go`, each role row carries a `transaction_groups` JSON column that mirrors its permissions. Tuples and JSON must move together or the stored record drifts from the registry. Follow 099's managed-role loop: marshal `authtypes.NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[roleName])` and `UPDATE role SET transaction_groups = ? WHERE org_id = ? AND type = 'managed' AND name = ?`. Serializing from the registry (which already contains your new grants) makes this step self-updating. Custom roles need no changes — only managed roles mirror the registry.
Register the new migration factory in `NewSQLMigrationProviderFactories` in `pkg/signoz/provider.go`, appended after the existing entries. The factory name passed to `factory.MustNewName` must match `^[a-z][a-z0-9_-]{0,30}$` — at most 31 characters, or it panics at startup; prefer a short name like `notification_template_tuples` over `add_notification_template_tuples`.
Ask the user whether backfill is needed rather than assuming — shipping a feature to existing orgs almost always means yes.
## Step 6: Update the golden testdata and regenerate CI-checked artifacts
If you changed `ManagedRoleToTransactions`, update `tests/integration/testdata/role/managed_role_transactions.json` to match — the `role/01_register.py` integration test compares each managed role's verb/type/kind matrix against this file and will fail otherwise.
If you touched the registries or `Kind.Enum()`, regenerate the derived artifacts. The `goci` GitHub workflow reruns each of these and fails the PR on any uncommitted diff:
```bash
go run cmd/enterprise/*.go generate openapi # docs/api/openapi.yml
go run cmd/enterprise/*.go generate authz # frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts
go run cmd/enterprise/*.go generate config transaction-groups # frontend/src/schemas/generated/transactionGroups.schema.json
```
Commit whatever they change; never hand-edit these generated files.
`generate authz` is allowlist-based: it only emits resources listed in the `allowedResources` map inside `runGenerateAuthz` in `cmd/authz.go`. For the new resource to surface in the UI's permission gating (AuthZButton and friends), add it there —
```go
coretypes.NewResourceRef(coretypes.ResourceMetaResourceThing).String(): true,
```
— then rerun `generate authz` and commit the regenerated `permissions.type.ts`. If the resource has no UI surface yet, skip the allowlist entry and the command produces no diff; that is expected.
## Step 7: Write integration tests
Read `references/integration-tests.md` for the suite layout, fixtures, and canonical allow/deny test shapes. The minimum bar for a new authz'd resource: each managed role's expected 200-vs-403 matrix on the new routes, and (if enterprise/FGA behavior matters) per-object scoping with a custom role — granted instance succeeds, non-granted instance gets 403, revoke flips it back to 403.
Python test conventions that are hard rules in this repo: inline `requests.*` calls directly (never wrap single API calls in helpers), always pass `timeout=`, and run `make py-fmt && make py-lint` before finishing.
## Verification
Run these before declaring the work done:
```bash
make go-build-community
```
Consistency greps — every one of these must hold:
- the new `Kind` var appears in BOTH the `Kinds` slice (`registry_kind.go`) and `Kind.Enum()` (`kind.go`)
- the three `generate` commands from step 6 have been run and leave `git diff` clean when re-run
- if the resource is UI-gated, it appears in `allowedResources` in `cmd/authz.go` and `permissions.type.ts` was regenerated
- the new `Resource` var appears in the `Resources` slice (`registry_resource.go`)
- every `ResourceDef` in the route file references the registered resource and one of its allowed verbs
- every route's `SecuritySchemes` scope matches its `ResourceDef` verb
- if `ManagedRoleToTransactions` changed, `managed_role_transactions.json` changed with it
Then run the relevant integration suites (`make py-test-setup` from the repo root boots the environment; pytest runs from `tests/`):
```bash
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/role/
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<your-suite>/
```
General repo conventions apply throughout: no code comments (the grouping comments in `registry_managed_role.go` are the sole exception), complete descriptive names, and never commit without an explicit ask.

View File

@@ -0,0 +1,114 @@
# Authz Integration Tests Reference
Existing authz suites to imitate: `integration/tests/role/` (managed roles, custom-role CRUD, FGA scoping), `integration/tests/serviceaccount/` (API-key auth by role, role assignment), `integration/tests/passwordauthn/04_role.py` (user role changes, editor denied on admin endpoints).
## Where a new test goes
- Suite package: `tests/integration/tests/<suite>/` — pick the existing suite matching the feature's domain, or create a new package when the feature is its own domain.
- File name: two-digit ordered prefix, `NN_<topic>.py` (`03_fga.py`, `04_roles.py`). Files in a suite run in order; setup tests early in the file establish state that later tests use.
- Golden/testdata JSON: `tests/integration/testdata/<suite>/`, loaded via `fixtures.fs.get_testdata_file_path`.
- If the change touched `ManagedRoleToTransactions`, update `tests/integration/testdata/role/managed_role_transactions.json` — `role/01_register.py` asserts every managed role's verb/type/kind matrix against it.
## Environment and running
`make` targets run from the repo root; direct `uv run pytest` commands run from `tests/`:
```bash
make py-test-setup # repo root — boot the stack once (~4 min), keep it with --reuse
cd tests
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/role/03_fga.py::test_read_scoped_to_granted_role
make py-test-teardown # repo root — destroy when done; never mix --reuse and --teardown
make py-fmt && make py-lint # repo root — mandatory before finishing
```
## Fixtures you'll need
From `tests/fixtures/auth.py` (registered via `pytest_plugins` in `tests/conftest.py`):
- `signoz: types.SigNoz` — the running stack; build URLs with `signoz.self.host_configs["8080"].get("/api/v1/...")`.
- `create_user_admin` — package-scoped; registers the first admin (`USER_ADMIN_EMAIL` / `USER_ADMIN_PASSWORD`). Take it as an unused argument in every test that needs the org to exist.
- `get_token(email, password) -> str` — bearer token for a user.
- `add_license(signoz, make_http_mocks, get_token)` — custom roles are enterprise-only; call it in a `test_apply_license` at the top of any suite that creates custom roles.
- `create_active_user(signoz, admin_token, email=..., role="signoz-viewer", password=..., name=...) -> user_id` — invite + activate. `role` is a managed role name looked up verbatim (e.g. `signoz-viewer`).
- `change_user_role(signoz, admin_token, user_id, old_role, new_role)` — e.g. `"signoz-viewer"` → your custom role name.
From `tests/fixtures/role.py`:
- `create_role(token, name, transaction_groups=None, description="") -> role_id` (fixture).
- `find_role_by_name(signoz, token, name) -> role_id` — plain function, import `from fixtures.role import find_role_by_name`.
- `transaction_group(relation, type_name, kind_name, selectors)` — builds one permission entry, e.g. `transaction_group("read", "metaresource", "thing", ["*"])` or with a specific instance name/ID as selector.
From `tests/fixtures/serviceaccount.py`:
- `create_service_account_with_key(signoz, token, name, role="signoz-admin") -> (sa_id, api_key)`.
- Service accounts authenticate with the header `SIGNOZ-API-KEY: <key>`; users with `Authorization: Bearer <token>`.
Hard rule: inline `requests.get/post/put/delete` calls directly in tests — do not wrap single API calls in helper functions or fixtures. Always pass `timeout=5`. Include the response text in assertion messages so failures are diagnosable.
## Shape 1: managed-role matrix (community-safe)
For each role, assert the expected status on each new route. Pattern from `serviceaccount/03_auth.py`:
```python
def test_editor_forbidden_on_admin_endpoint(signoz, create_user_admin, get_token):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_, api_key = create_service_account_with_key(signoz, token, "sa-role-editor", role="signoz-editor")
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/service_accounts"),
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"expected 403, got {resp.status_code}: {resp.text}"
```
The user-token variant creates an editor/viewer via `create_active_user` and asserts 403 on the route, 200 for admin.
## Shape 2: per-object FGA scoping (enterprise, custom role)
Grant instance verbs on one object only; assert the other stays forbidden. Structure from `role/03_fga.py`:
```python
def test_apply_license(signoz, create_user_admin, make_http_mocks, get_token):
add_license(signoz, make_http_mocks, get_token)
def test_setup_actor(signoz, create_user_admin, get_token, create_role):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(
admin_token,
"thing-fga-actor",
[
transaction_group("read", "metaresource", "thing", [_TARGET_A_ID]),
transaction_group("list", "metaresource", "thing", ["*"]),
],
)
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="thing fga actor")
change_user_role(signoz, admin_token, user_id, "signoz-viewer", "thing-fga-actor")
def test_read_scoped_to_grant(signoz, create_user_admin, get_token):
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/things/{_TARGET_A_ID}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, resp.text
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/things/{_TARGET_B_ID}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"expected 403, got {resp.status_code}: {resp.text}"
```
Key semantics to test (they mirror the selector rules on the route side):
- `read`/`update`/`delete` granted on a specific selector authorize only that instance; `*` authorizes all.
- `create` and `list` are collection-scoped — only a `*` grant makes them pass, and `list` on `*` returns every instance including ones the caller can't `read` individually.
- Revoking the grant (update the role's transaction groups, or `change_user_role` back) flips the previously-allowed call to 403.
Module-level constants for names/emails (see `role/03_fga.py` — `_ACTOR_ROLE_NAME`, `_TARGET_A`) keep the setup test and the assertion tests in sync.
## What "done" looks like
- Every new route appears in at least one allow case and one deny case.
- Suites that create custom roles start with `test_apply_license`.
- `make py-fmt && make py-lint` pass.
- The suite passes against a `--reuse` stack: `uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/`.

View File

@@ -0,0 +1,155 @@
# Route Wiring Reference
All examples below are real code from `pkg/apiserver/signozapiserver/serviceaccount.go`. Match this style exactly.
## Basic route: create (wildcard selector, response-phase ID)
```go
if err := router.Handle("/api/v1/service_accounts", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
Description: "This endpoint creates a service account",
Request: new(serviceaccounttypes.PostableServiceAccount),
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
```
Create checks the wildcard (can the caller create *any* instance?) but still records the new instance's ID for audit via `ResponseJSONPath` — the ID only exists after the handler runs.
Instance routes (read/update/delete) differ only in verb, `ID: coretypes.PathParam("id")`, and `Selector: coretypes.IDSelector`. List routes use `WildcardSelector` and omit `ID` entirely.
Pick the `Category` matching the resource's domain — grep `ActionCategory` in `pkg/types/coretypes/` for the registered values (`ActionCategoryAccessControl` is for access-control resources; observability resources use their own categories).
## ID extractors (`pkg/types/coretypes/extractor.go`)
| Extractor | Phase | Use for |
|---|---|---|
| `PathParam("id")` | request | ID in the URL path |
| `BodyJSONPath("data.id")` | request | ID in the request body (gjson path) |
| `BodyJSONArray("ids")` | request | multiple IDs in a body array |
| `ResponseJSONPath("data.id")` | response | ID known only after the handler (create) |
| `NewResourceIDExtractor(phase, fn)` | either | ID needs a lookup (see below) |
| `OneID(extractor)` | — | lifts a single-ID extractor where a multi-ID one is required (`SourceIDs`/`TargetIDs`/`ChildIDs`) |
Custom extractor example — the route path carries a service-account-role ID but the check needs the underlying role ID, so the extractor resolves it through the module:
```go
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.RoleID.String(), nil
})
}
```
## Custom SelectorFunc
Use one when the extracted ID is not what FGA objects use. Roles are the canonical case: routes receive a role UUID, but FGA role objects are keyed by role *name*. Always include the wildcard alongside the specific selector — authorization on `*` implies authorization on every instance:
```go
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
return nil, err
}
role, err := provider.authzService.Get(ctx, orgID, roleID)
if err != nil {
return nil, err
}
return []coretypes.Selector{
resource.Type().MustSelector(role.Name),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}
```
## Linking routes: siblings (peer-to-peer)
Attaching a role to a service account requires `attach` on **both** resources — the same verb checked on each side. Advertise both scopes:
```go
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
// ...
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
```
The detach route (`DELETE .../service_account_roles/{id}`) uses `VerbDetach`, and its path `{id}` identifies the **assignment** (the service account role), not the role. Both IDs are resolved from it via lookup-helper extractors — `provider.serviceAccountIDExtractor()` and `provider.roleIDExtractor()` fetch the assignment and return its service account ID and role ID.
## Linking routes: parent-child
Creating an API key *under* a service account checks two different things: `create` on the child resource, and `attach` on the parent. Declare both defs in the same `WithResourceDefs`:
```go
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorAPIKey,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceServiceAccount,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorAPIKey,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
```
Within the parent-child def, the child is only recorded for audit — the authz check on the child comes from its own `BasicResourceDef`. Deletion mirrors this with `VerbDelete` on the child and `VerbDetach` on the parent (see the `/api/v1/service_accounts/{id}/keys/{fid}` DELETE route).
## Legacy gates — do not use for new routes
`ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles". They give up per-resource granularity, produce no audit resources, and don't advertise scopes. They exist for old routes; new routes use `CheckResources` + `ResourceDef`. `OpenAccess` performs no authorization at all (authentication still applies — used for `/me`-style routes); `CheckWithoutClaims` is for anonymous routes such as public dashboards.
Full def types live in `pkg/http/handler/resourcedef.go`; middleware behavior in `pkg/http/middleware/authz.go` and `pkg/http/middleware/resource.go`.

8
.github/CODEOWNERS vendored
View File

@@ -290,6 +290,14 @@ go.mod @therealpandey
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
## Saved Views
/frontend/src/container/SavedViews/ @aks07
/frontend/src/container/Home/SavedViews/ @aks07
/frontend/src/hooks/saveViews/ @aks07
## Bottom Strip
/frontend/src/container/BottomStrip/ @aks07
## Storybook
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad

File diff suppressed because it is too large Load Diff

View File

@@ -93,9 +93,9 @@ tests/
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
│ ├── auth.ts
│ └── dashboards.ts
│ └── logs-explorer.ts
├── testdata/ # static data files (JSON) used by helpers and tests
│ └── apm-metrics.json # (example)
│ └── traces/large-trace.json # (example)
├── tests/ # Playwright .spec.ts files, one dir per feature area
│ └── alerts/
│ └── alerts.spec.ts # (example)
@@ -110,8 +110,8 @@ tests/
These two folders look similar but mean different things:
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoLogsExplorer(page)`) and the constants those helpers and the tests both refer to (e.g. `LINKED_ROW_CLASS`). Constants live next to the helpers that use them so a single import line in a test covers both.
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `traces/large-trace.json`, a real trace the trace-details helpers seed.
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
@@ -128,7 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
@@ -187,7 +187,7 @@ Each spec follows these principles:
});
```
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `traces/large-trace.json` is a real trace that `loadLargeTrace` (in `helpers/trace-details.ts`) seeds for the trace-details tests.
## How to write an E2E test?

View File

@@ -7,24 +7,66 @@ import (
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
func (g *gcpcloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
var metricNames []string
var sinkFilters []string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
if svcDef.TelemetryCollectionStrategy.GCP == nil {
continue
}
strategy := svcDef.TelemetryCollectionStrategy.GCP
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
}
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
}
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
GCP: connectionArtifact,
}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
@@ -34,3 +76,7 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}

View File

@@ -2,7 +2,6 @@ package implcloudintegration
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -171,18 +170,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
@@ -195,41 +182,36 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
}
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
return nil, errors.Newf(
errors.TypeAlreadyExists,
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
)
}
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
@@ -498,11 +480,45 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
}
// NOTE: not adding stats for services for now.
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
if err == nil {
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
}
return stats, nil
}
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
response := cloudintegrationtypes.NewAgentCheckInResponse(
providerAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
)
if account.AgentReport == nil {
return response, nil
}
if syncState == nil {
return response, nil
}
account.UpdateSyncState(syncState)
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
return response, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil

View File

@@ -163,9 +163,9 @@
"@commitlint/config-conventional": "20.4.4",
"@jest/globals": "30.4.1",
"@jest/types": "30.2.0",
"@storybook/addon-a11y": "10.5.9",
"@storybook/addon-docs": "10.5.9",
"@storybook/react-vite": "10.5.9",
"@storybook/addon-a11y": "10.6.3",
"@storybook/addon-docs": "10.6.3",
"@storybook/react-vite": "10.6.3",
"@storybook/test-runner": "0.24.5",
"@testing-library/dom": "8.20.0",
"@testing-library/jest-dom": "5.16.5",
@@ -213,10 +213,10 @@
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.4",
"storybook": "10.5.9",
"storybook": "10.6.3",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"ts-jest": "29.4.9",
"ts-jest": "29.4.15",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",

385
frontend/pnpm-lock.yaml generated
View File

@@ -8,9 +8,9 @@ overrides:
'@babel/core@<=7.29.0': '>=7.29.6 <8'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.2 <5'
'@xmldom/xmldom@<0.8.15': '>=0.8.15 <0.9'
brace-expansion@<1.1.18: '>=1.1.18 <2'
brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <3'
brace-expansion@>=5.0.0 <5.0.9: '>=5.0.9 <6'
brace-expansion@<1.1.21: '>=1.1.21 <2'
brace-expansion@>=2.0.0 <2.1.7: '>=2.1.7 <3'
brace-expansion@>=5.0.0 <5.0.12: '>=5.0.12 <6'
browserslist@<4.28.7: '>=4.28.7 <5'
cookie@<0.7.0: '>=0.7.1 <1'
dompurify@<=3.4.12: '>=3.4.13 <4'
@@ -364,17 +364,17 @@ importers:
specifier: 30.2.0
version: 30.2.0
'@storybook/addon-a11y':
specifier: 10.5.9
version: 10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
specifier: 10.6.3
version: 10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/addon-docs':
specifier: 10.5.9
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
specifier: 10.6.3
version: 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react-vite':
specifier: 10.5.9
version: 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
specifier: 10.6.3
version: 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
'@storybook/test-runner':
specifier: 0.24.5
version: 0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
version: 0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
'@testing-library/dom':
specifier: 8.20.0
version: 8.20.0
@@ -514,8 +514,8 @@ importers:
specifier: 0.35.4
version: 0.35.4(@types/node@16.18.25)
storybook:
specifier: 10.5.9
version: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
specifier: 10.6.3
version: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
stylelint:
specifier: 17.15.0
version: 17.15.0(typescript@5.9.3)
@@ -523,8 +523,8 @@ importers:
specifier: 4.1.0
version: 4.1.0
ts-jest:
specifier: 29.4.9
version: 29.4.9(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3)
specifier: 29.4.15
version: 29.4.15(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3)
typescript-plugin-css-modules:
specifier: 5.2.0
version: 5.2.0(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))(typescript@5.9.3)
@@ -1525,8 +1525,8 @@ packages:
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.11.2':
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
'@emnapi/core@1.11.0':
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -1534,8 +1534,8 @@ packages:
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@emnapi/runtime@1.11.0':
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
@@ -2170,6 +2170,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -2452,106 +2455,106 @@ packages:
'@oxc-project/types@0.127.0':
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==}
cpu: [arm]
os: [android]
'@oxc-resolver/binding-android-arm64@11.24.2':
resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
'@oxc-resolver/binding-android-arm64@11.21.2':
resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==}
cpu: [arm64]
os: [android]
'@oxc-resolver/binding-darwin-arm64@11.24.2':
resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
'@oxc-resolver/binding-darwin-arm64@11.21.2':
resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==}
cpu: [arm64]
os: [darwin]
'@oxc-resolver/binding-darwin-x64@11.24.2':
resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
'@oxc-resolver/binding-darwin-x64@11.21.2':
resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==}
cpu: [x64]
os: [darwin]
'@oxc-resolver/binding-freebsd-x64@11.24.2':
resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
'@oxc-resolver/binding-freebsd-x64@11.21.2':
resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==}
cpu: [x64]
os: [freebsd]
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==}
cpu: [arm64]
os: [openharmony]
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==}
cpu: [arm64]
os: [win32]
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==}
cpu: [x64]
os: [win32]
@@ -3660,44 +3663,26 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@storybook/addon-a11y@10.5.9':
resolution: {integrity: sha512-MvXkoJIVRcZrqhWi+wynYAmFkvTWm5aHdJfYohhgPeEJVDRF91zejtOsN7zPRE/lQvBDY+IiomKxeRN3UhWNVw==}
'@storybook/addon-a11y@10.6.3':
resolution: {integrity: sha512-nqvjpmu5g+PPtEKwgE0qAi4wfuQ9yngOb/1Gm24pBBZOuRsd3UMEBjKORSuAGrIAz9X3LCFfQi7sCcBFXvFbwg==}
peerDependencies:
storybook: ^10.5.9
storybook: ^10.6.3
'@storybook/addon-docs@10.5.9':
resolution: {integrity: sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==}
'@storybook/addon-docs@10.6.3':
resolution: {integrity: sha512-365f712au4w/xvFqEKY6TIvTeGGAY7FKRB8zP4CFa3VSv9oyxHaq/ublFg4YDbCeRagppwl1PZ6k4+PoUor3+A==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
storybook: ^10.6.3
peerDependenciesMeta:
'@types/react':
optional: true
'@storybook/builder-vite@10.5.9':
resolution: {integrity: sha512-Zg4JbGQiHFPGlFJ9HM+XPgzKmU/RFPCymhohVRJhBBYfmgaQgz0flWWzscseCDpl638MNd8/r/H+nwuoBgSYDg==}
'@storybook/builder-vite@10.6.3':
resolution: {integrity: sha512-1+WCZZg/fPxxyHDB5sFKniFt9/4RFUIzoNqMujv+BLRROM+gQeyUHvdvoyZOZ51+P8ee+/PgnNKHjsTupV+nOA==}
peerDependencies:
storybook: ^10.5.9
storybook: ^10.6.3
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@storybook/csf-plugin@10.5.9':
resolution: {integrity: sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==}
peerDependencies:
esbuild: '>=0.28.1 <0.29.0'
rollup: '*'
storybook: ^10.5.9
vite: '*'
webpack: '*'
peerDependenciesMeta:
esbuild:
optional: true
rollup:
optional: true
vite:
optional: true
webpack:
optional: true
'@storybook/global@5.0.0':
resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
@@ -3706,40 +3691,40 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/react-dom-shim@10.5.9':
resolution: {integrity: sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==}
'@storybook/react-dom-shim@10.6.3':
resolution: {integrity: sha512-cJUuPrieiTl06kzlgaU89iG+CtLAgUTt0Xdvm8qFT9yR2qwafSaswhwYWlZCNX4nWuOu7eK7uzWTwzKqyDBOWQ==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
storybook: ^10.6.3
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@storybook/react-vite@10.5.9':
resolution: {integrity: sha512-mDI+UssrOEP8IwgBsefLG+gqW1gZbZEPbpH5PoHK/P1WPi8BfrLEqrFi9H9wmMkal/Vh8SmL1awgOi0ir3XwcQ==}
'@storybook/react-vite@10.6.3':
resolution: {integrity: sha512-geWwLi9JnaRG2t9bYBfz+94skARzzXNT1Toaf0lk7Oog13rtNK7ay2pWNm9SP6/L2S7mEjoWu9+zHLpwxXvfIw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
storybook: ^10.6.3
typescript: '>= 4.9.x'
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
typescript:
optional: true
'@storybook/react@10.5.9':
resolution: {integrity: sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==}
'@storybook/react@10.6.3':
resolution: {integrity: sha512-3tfAkZNdUyx3I4IjM8dgbNWg4k0gj89Hi1MPiawfTybRRWhvnX2YqV9Ut4mrud7YzRv0v+U7+ZYEvj+maUPaow==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.5.9
storybook: ^10.6.3
typescript: '>= 4.9.x'
peerDependenciesMeta:
'@types/react':
@@ -4761,14 +4746,14 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -6149,8 +6134,8 @@ packages:
resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
handlebars@4.7.9:
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
handlebars@4.7.10:
resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==}
engines: {node: '>=0.4.7'}
hasBin: true
@@ -7207,6 +7192,9 @@ packages:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magic-string@1.4.3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
@@ -7782,8 +7770,8 @@ packages:
resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.24.2:
resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
oxc-resolver@11.21.2:
resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==}
oxfmt@0.73.0:
resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==}
@@ -9175,8 +9163,8 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
storybook@10.5.9:
resolution: {integrity: sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==}
storybook@10.6.3:
resolution: {integrity: sha512-lNBvcZFG44C98KvQHYzfq1O46m+sl94zY1+na5/9E3ihtNMbvpOgYyMFMIcWYUvOMo3OM4q9jzvB73KU20bU8w==}
hasBin: true
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -9465,8 +9453,8 @@ packages:
ts-easing@0.2.0:
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
ts-jest@29.4.9:
resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==}
ts-jest@29.4.15:
resolution: {integrity: sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -11351,7 +11339,7 @@ snapshots:
react: 18.2.0
tslib: 2.8.1
'@emnapi/core@1.11.2':
'@emnapi/core@1.11.0':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
@@ -11369,7 +11357,7 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.2':
'@emnapi/runtime@1.11.0':
dependencies:
tslib: 2.8.1
optional: true
@@ -12090,6 +12078,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.1
@@ -12195,10 +12185,10 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)':
dependencies:
'@emnapi/core': 1.11.2
'@emnapi/runtime': 1.11.2
'@emnapi/core': 1.11.0
'@emnapi/runtime': 1.11.0
'@tybys/wasm-util': 0.10.3
optional: true
@@ -12411,65 +12401,65 @@ snapshots:
'@oxc-project/types@0.127.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
'@oxc-resolver/binding-android-arm-eabi@11.21.2':
optional: true
'@oxc-resolver/binding-android-arm64@11.24.2':
'@oxc-resolver/binding-android-arm64@11.21.2':
optional: true
'@oxc-resolver/binding-darwin-arm64@11.24.2':
'@oxc-resolver/binding-darwin-arm64@11.21.2':
optional: true
'@oxc-resolver/binding-darwin-x64@11.24.2':
'@oxc-resolver/binding-darwin-x64@11.21.2':
optional: true
'@oxc-resolver/binding-freebsd-x64@11.24.2':
'@oxc-resolver/binding-freebsd-x64@11.21.2':
optional: true
'@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
'@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2':
optional: true
'@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
'@oxc-resolver/binding-linux-arm-musleabihf@11.21.2':
optional: true
'@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
'@oxc-resolver/binding-linux-arm64-gnu@11.21.2':
optional: true
'@oxc-resolver/binding-linux-arm64-musl@11.24.2':
'@oxc-resolver/binding-linux-arm64-musl@11.21.2':
optional: true
'@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.2':
optional: true
'@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.2':
optional: true
'@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
'@oxc-resolver/binding-linux-riscv64-musl@11.21.2':
optional: true
'@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
'@oxc-resolver/binding-linux-s390x-gnu@11.21.2':
optional: true
'@oxc-resolver/binding-linux-x64-gnu@11.24.2':
'@oxc-resolver/binding-linux-x64-gnu@11.21.2':
optional: true
'@oxc-resolver/binding-linux-x64-musl@11.24.2':
'@oxc-resolver/binding-linux-x64-musl@11.21.2':
optional: true
'@oxc-resolver/binding-openharmony-arm64@11.24.2':
'@oxc-resolver/binding-openharmony-arm64@11.21.2':
optional: true
'@oxc-resolver/binding-wasm32-wasi@11.24.2':
'@oxc-resolver/binding-wasm32-wasi@11.21.2':
dependencies:
'@emnapi/core': 1.11.2
'@emnapi/runtime': 1.11.2
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
'@emnapi/core': 1.11.0
'@emnapi/runtime': 1.11.0
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)
optional: true
'@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
'@oxc-resolver/binding-win32-arm64-msvc@11.21.2':
optional: true
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
optional: true
'@oxfmt/binding-android-arm-eabi@0.73.0':
@@ -13412,49 +13402,32 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-a11y@10.5.9(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
'@storybook/addon-a11y@10.6.3(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
'@storybook/global': 5.0.0
axe-core: 4.13.0
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
'@storybook/addon-docs@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
'@storybook/addon-docs@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@18.0.26)(react@18.2.0)
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/icons': 2.1.0(react@18.2.0)
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react-dom-shim': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
ts-dedent: 2.3.0
unplugin: 2.3.11
optionalDependencies:
'@types/react': 18.0.26
transitivePeerDependencies:
- '@types/react-dom'
- esbuild
- rollup
- vite
- webpack
'@storybook/builder-vite@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
'@storybook/builder-vite@10.6.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
'@storybook/csf-plugin': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
ts-dedent: 2.3.0
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
transitivePeerDependencies:
- esbuild
- rollup
- webpack
'@storybook/csf-plugin@10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.28.1
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
'@storybook/global@5.0.0': {}
@@ -13462,28 +13435,28 @@ snapshots:
dependencies:
react: 18.2.0
'@storybook/react-dom-shim@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
'@storybook/react-dom-shim@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))':
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
optionalDependencies:
'@types/react': 18.0.26
'@types/react-dom': 18.0.10
'@storybook/react-vite@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(esbuild@0.28.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
'@storybook/react-vite@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(typescript@5.9.3)
'@rollup/pluginutils': 5.4.0
'@storybook/builder-vite': 10.5.9(esbuild@0.28.1)(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
'@storybook/builder-vite': 10.6.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)
empathic: 2.0.1
magic-string: 0.30.8
magic-string: 1.4.3
react: 18.2.0
react-docgen: 8.0.3
react-dom: 18.2.0(react@18.2.0)
resolve: 1.22.11
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
tsconfig-paths: 4.2.0
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1)
optionalDependencies:
@@ -13491,20 +13464,18 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- esbuild
- rollup
- supports-color
- webpack
'@storybook/react@10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
'@storybook/react@10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(typescript@5.9.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
'@storybook/react-dom-shim': 10.6.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))
react: 18.2.0
react-docgen: 8.0.3
react-docgen-typescript: 2.4.0(typescript@5.9.3)
react-dom: 18.2.0(react@18.2.0)
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
optionalDependencies:
'@types/react': 18.0.26
'@types/react-dom': 18.0.10
@@ -13512,7 +13483,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@storybook/test-runner@0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))':
'@storybook/test-runner@0.24.5(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0))(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.7
@@ -13534,7 +13505,7 @@ snapshots:
playwright: 1.57.0
playwright-core: 1.57.0
rimraf: 3.0.2
storybook: 10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
storybook: 10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0)
uuid: 8.3.2
transitivePeerDependencies:
- '@swc/helpers'
@@ -13674,7 +13645,7 @@ snapshots:
'@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.5.0
aria-query: 5.1.3
aria-query: 5.3.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
@@ -14662,16 +14633,16 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@1.1.18:
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.4:
brace-expansion@2.1.7:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -16125,7 +16096,7 @@ snapshots:
graphql@16.8.1: {}
handlebars@4.7.9:
handlebars@4.7.10:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
@@ -17498,6 +17469,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@1.4.3:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
make-dir@3.1.0:
dependencies:
semver: 6.3.1
@@ -17908,19 +17883,19 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.21
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.4
brace-expansion: 2.1.7
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.4
brace-expansion: 2.1.7
minimist@1.2.8: {}
@@ -18304,27 +18279,27 @@ snapshots:
'@oxc-parser/binding-win32-ia32-msvc': 0.127.0
'@oxc-parser/binding-win32-x64-msvc': 0.127.0
oxc-resolver@11.24.2:
oxc-resolver@11.21.2:
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.24.2
'@oxc-resolver/binding-android-arm64': 11.24.2
'@oxc-resolver/binding-darwin-arm64': 11.24.2
'@oxc-resolver/binding-darwin-x64': 11.24.2
'@oxc-resolver/binding-freebsd-x64': 11.24.2
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2
'@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2
'@oxc-resolver/binding-linux-arm64-gnu': 11.24.2
'@oxc-resolver/binding-linux-arm64-musl': 11.24.2
'@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2
'@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2
'@oxc-resolver/binding-linux-riscv64-musl': 11.24.2
'@oxc-resolver/binding-linux-s390x-gnu': 11.24.2
'@oxc-resolver/binding-linux-x64-gnu': 11.24.2
'@oxc-resolver/binding-linux-x64-musl': 11.24.2
'@oxc-resolver/binding-openharmony-arm64': 11.24.2
'@oxc-resolver/binding-wasm32-wasi': 11.24.2
'@oxc-resolver/binding-win32-arm64-msvc': 11.24.2
'@oxc-resolver/binding-win32-x64-msvc': 11.24.2
'@oxc-resolver/binding-android-arm-eabi': 11.21.2
'@oxc-resolver/binding-android-arm64': 11.21.2
'@oxc-resolver/binding-darwin-arm64': 11.21.2
'@oxc-resolver/binding-darwin-x64': 11.21.2
'@oxc-resolver/binding-freebsd-x64': 11.21.2
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2
'@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2
'@oxc-resolver/binding-linux-arm64-gnu': 11.21.2
'@oxc-resolver/binding-linux-arm64-musl': 11.21.2
'@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2
'@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2
'@oxc-resolver/binding-linux-riscv64-musl': 11.21.2
'@oxc-resolver/binding-linux-s390x-gnu': 11.21.2
'@oxc-resolver/binding-linux-x64-gnu': 11.21.2
'@oxc-resolver/binding-linux-x64-musl': 11.21.2
'@oxc-resolver/binding-openharmony-arm64': 11.21.2
'@oxc-resolver/binding-wasm32-wasi': 11.21.2
'@oxc-resolver/binding-win32-arm64-msvc': 11.21.2
'@oxc-resolver/binding-win32-x64-msvc': 11.21.2
oxfmt@0.73.0:
dependencies:
@@ -19922,7 +19897,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
storybook@10.5.9(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0):
storybook@10.6.3(@types/react@18.0.26)(prettier@3.8.3)(react@18.2.0):
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.1.0(react@18.2.0)
@@ -19936,7 +19911,7 @@ snapshots:
jsonc-parser: 3.3.1
open: 10.2.0
oxc-parser: 0.127.0
oxc-resolver: 11.24.2
oxc-resolver: 11.21.2
recast: 0.23.21
semver: 7.8.5
use-sync-external-store: 1.6.0(react@18.2.0)
@@ -20254,11 +20229,11 @@ snapshots:
ts-easing@0.2.0: {}
ts-jest@29.4.9(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3):
ts-jest@29.4.15(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.2.0)(babel-jest@29.6.4(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
handlebars: 4.7.10
jest: 30.2.0(@types/node@16.18.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.16.2)(@types/node@16.18.25)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2

View File

@@ -20,13 +20,13 @@ overrides:
'@xmldom/xmldom@<0.8.15': '>=0.8.15 <0.9'
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
brace-expansion@<1.1.18: '>=1.1.18 <2'
brace-expansion@<1.1.21: '>=1.1.21 <2'
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
'brace-expansion@>=2.0.0 <2.1.7': '>=2.1.7 <3'
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
'brace-expansion@>=5.0.0 <5.0.12': '>=5.0.12 <6'
# via: direct devDep @babel/core > @babel/helper-compilation-targets (browserslist ^4.24.0)
# remove: blocked, the range is open so the floor is what pulls the fix.
# Also lifts baseline-browser-mapping, which browserslist@4.28.7 floors at ^2.11.20

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import {
QueryKeyValueRequestProps,
QueryKeyValueSuggestionsResponseProps,
} from 'types/api/querySuggestions/types';
/**
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
* `api/generated/services/fields` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
export const getValueSuggestions = (
props: QueryKeyValueRequestProps,
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
const { signal, key, searchText, signalSource, metricName } = props;
const encodedSignal = encodeURIComponent(signal);
const encodedKey = encodeURIComponent(key);
const encodedMetricName = encodeURIComponent(metricName || '');
const encodedSearchText = encodeURIComponent(searchText);
const encodedSource = encodeURIComponent(signalSource || '');
return axios.get(
`/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`,
);
};

View File

@@ -1,90 +0,0 @@
interface EmptyQuickFilterIconProps {
width?: number;
height?: number;
className?: string;
}
function EmptyQuickFilterIcon({
width = 32,
height = 32,
className,
}: EmptyQuickFilterIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M17.948 6.906h-6.62V6.24h6.62v.667ZM17.948 10.293h-6.62v-.667h6.62v.667Z"
fill="#616161"
/>
<path
d="M16.175 29.403H13.62V3.442s.238-.554 1.278-.554 1.278.554 1.278.554v25.96h-.002Z"
fill="#9E9E9E"
/>
<path
d="M15.52 2.971v20.974H13.62v1.91c.323.001.614.04.876.097a1.3 1.3 0 0 1 1.024 1.269v2.182h.656V3.442c-.002 0-.14-.318-.658-.471Z"
fill="#757575"
/>
<path
d="M22.363 2.737a5.382 5.382 0 0 0-5.382 5.402 5.365 5.365 0 0 0 5.382 5.382 5.378 5.378 0 0 0 5.382-5.382c0-2.975-2.407-5.402-5.382-5.402Z"
fill="#2196F3"
/>
<path
d="M24.714 4.749c-.338-.085-1.01-.174-2.349-.174-1.337 0-2.01.087-2.348.174-.2.05-.87.328-.87 1.077v4.618c0 .253.205.46.46.46h.17v.51c0 .15.12.27.268.27h.545c.149 0 .269-.12.269-.27v-.51h3.008v.51c0 .15.12.27.27.27h.544c.149 0 .269-.12.269-.27v-.51h.173a.46.46 0 0 0 .46-.46V5.826c0-.717-.669-1.029-.869-1.077Zm-3.802.366c0-.113.091-.204.205-.204h2.493c.113 0 .204.09.204.204v.362a.204.204 0 0 1-.204.205h-2.493a.204.204 0 0 1-.205-.205v-.362Zm.042 4.864a.138.138 0 0 1-.137.138h-.55a.469.469 0 0 1-.468-.469v-.246c0-.076.062-.138.138-.138h.548c.258 0 .47.209.47.469v.246Zm3.973-.33a.469.469 0 0 1-.468.468h-.55a.138.138 0 0 1-.137-.138v-.246c0-.258.209-.47.469-.47h.549c.075 0 .137.063.137.139v.246Zm.125-2.007c0 .297-.645.817-2.689.817-2.046 0-2.689-.482-2.689-.817V6.277c0-.089.09-.31.311-.31h4.791c.223 0 .276.224.276.31v1.365Z"
fill="#fff"
/>
<path
d="M18.861 24.652h-7.926a.506.506 0 0 1-.507-.507V14.99c0-.28.227-.507.507-.507h7.924c.28 0 .507.227.507.507v9.155c0 .28-.227.507-.505.507Z"
fill="#F5F5F5"
/>
<path
opacity=".8"
d="M18.006 23.139H11.79l-.017-7.236h6.217l.016 7.236Z"
fill="#82AEC0"
/>
<path d="M14.66 23.234v-7.33h.444v7.33h-.445Z" fill="#F5F5F5" />
<path d="M14.66 15.903h-2.887v.605h2.886v-.605Z" fill="#616161" />
<path
d="M18.03 21.878h-6.264v-.222h6.264v.223ZM18.03 20.412h-6.264v-.222h6.264v.222ZM17.99 18.945h-6.217v-.222h6.217v.222ZM17.99 17.481h-6.224v-.222h6.224v.222Z"
fill="#F5F5F5"
/>
<path
d="M17.988 18.534H15.1v.605h2.887v-.605ZM14.672 19.999h-2.877v.604h2.877V20Z"
fill="#616161"
/>
<path
d="M10.935 14.817a.173.173 0 0 0-.173.173v9.155c0 .096.077.174.173.174h7.926a.173.173 0 0 0 .171-.174V14.99a.173.173 0 0 0-.173-.173h-7.924Zm-.84.173a.84.84 0 0 1 .84-.84h7.924a.84.84 0 0 1 .84.84v9.155a.84.84 0 0 1-.838.84h-7.926a.84.84 0 0 1-.84-.84V14.99Z"
fill="#9E9E9E"
/>
<path
d="M10.968 24.323c-.344.031-.344-.195-.344-.258V15.1c0-.251.204-.455.455-.455h7.693c.21 0 .305.126.262.384 0 0-.026-.165-.226-.165h-7.726a.237.237 0 0 0-.236.236v8.968c0 .21.122.256.122.256Z"
fill="#757575"
/>
<path d="M12.268 4.933H4.695v6.577h7.573V4.933Z" fill="#FFCA28" />
<path
d="M11.846 4.933c.233 0 .422.189.422.422v5.733a.422.422 0 0 1-.422.422H5.12a.422.422 0 0 1-.423-.422V5.355c0-.233.19-.422.423-.422h6.726Zm0-.445H5.12a.868.868 0 0 0-.867.867v5.733c0 .478.389.867.867.867h6.726a.868.868 0 0 0 .867-.867V5.355a.866.866 0 0 0-.867-.867Z"
fill="#9E9E9E"
/>
<path
d="M12.27 7.308H4.696v-.444h7.575v.444ZM12.27 9.579H4.696v-.444h7.575v.444Z"
fill="#FFFDE7"
/>
<path
d="M7.066 5.664H5.162v.502h1.904v-.502ZM6.597 10.27H5.162v.503h1.435v-.502ZM11.648 10.27h-1.435v.503h1.435v-.502ZM11.648 5.664h-1.435v.502h1.435v-.502ZM10.302 7.968h-.827v.502h.827v-.502ZM11.648 7.968h-.826v.502h.826v-.502ZM7.802 7.968h-2.64v.502h2.64v-.502Z"
fill="#757575"
/>
</svg>
);
}
EmptyQuickFilterIcon.defaultProps = {
width: 32,
height: 32,
className: '',
};
export default EmptyQuickFilterIcon;

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,230 +0,0 @@
.checkbox-filter {
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
border-bottom: 1px solid var(--l1-border);
.search {
input {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
&::placeholder {
color: var(--l3-foreground);
}
--input-font-size: 14px;
--input-border-color: var(--l1-border);
--input-focus-border-color: var(--primary-background);
--input-focus-outline-width: 0;
--input-focus-outline-offset: 0;
}
}
.filter-header-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
.left-action {
display: flex;
align-items: center;
gap: 6px;
.title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
}
.right-action {
display: flex;
align-items: center;
min-width: 48px;
.clear-all {
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
}
}
}
.values {
display: flex;
flex-direction: column;
gap: 8px;
.filter-separator {
height: 1px;
background-color: var(--l1-border);
margin: 4px 0;
}
.value {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
.checkbox-value-section {
display: flex;
align-items: center;
gap: 4px;
width: calc(100% - 24px);
cursor: pointer;
.value-string {
width: 100%;
}
&.filter-disabled {
cursor: not-allowed;
.value-string {
color: var(--l3-foreground);
}
.only-btn,
.toggle-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
// one swaps in place instead of shifting the row layout.
.value-actions {
display: grid;
align-items: center;
justify-items: end;
> * {
grid-area: 1 / 1;
}
}
.only-btn,
.toggle-btn {
display: none;
align-items: center;
justify-content: center;
height: 21px;
opacity: 0;
transform: translateX(4px);
// `display` is animated as a discrete property so the button
// stays mounted through its fade-out on exit.
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
&:hover {
background-color: unset;
}
}
}
// Hovering the label/value area reveals the "Only"/"All" action.
.checkbox-value-section:hover .only-btn {
display: flex;
opacity: 1;
transform: translateX(0);
// Entry animation start state (fires on the display switch).
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
// Hovering the checkbox reveals the "Toggle" action.
[data-slot='checkbox']:hover ~ .checkbox-value-section .toggle-btn {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.only-btn,
.toggle-btn {
transition: none;
}
}
}
.no-data {
align-self: center;
}
.show-more {
display: flex;
align-items: center;
justify-content: center;
.show-more-text {
color: var(--accent-primary);
cursor: pointer;
}
}
.go-to-docs {
display: flex;
flex-direction: column;
gap: 44px;
&__container {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
&-message {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
&__button {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
margin: 0 0 4px;
padding: 0;
&-text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
.label-false {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--danger-background);
}
.label-true {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--bg-forest-500);
}

View File

@@ -1,476 +0,0 @@
import { UseQueryResult } from 'react-query';
import userEvent from '@testing-library/user-event';
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { SuccessResponse } from 'types/api';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import CheckboxFilter from './Checkbox';
// Mock the query builder hook
jest.mock('hooks/queryBuilder/useQueryBuilder');
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
// Mock the aggregate values hook
jest.mock('hooks/queryBuilder/useGetAggregateValues');
const mockUseGetAggregateValues = jest.mocked(useGetAggregateValues);
// Mock the key value suggestions hook
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
useGetQueryKeyValueSuggestions,
);
interface MockFilterConfig {
title: string;
attributeKey: {
key: string;
dataType: DataTypes;
type: string;
};
dataSource: DataSource;
defaultOpen: boolean;
type: FiltersType;
}
const SERVICE_NAME_KEY = 'service.name';
const OTEL_DEMO = 'otel-demo';
const SAMPLE_FLASK = 'sample-flask';
const OTLP_PYTHON = 'otlp-python';
const MQ_KAFKA = 'mq-kafka';
const MOCK_SERVICE_NAMES = [MQ_KAFKA, OTEL_DEMO, OTLP_PYTHON, SAMPLE_FLASK];
const createMockFilter = (
overrides: Partial<MockFilterConfig> = {},
): MockFilterConfig => ({
title: 'Service Name',
attributeKey: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.LOGS,
defaultOpen: false,
type: FiltersType.CHECKBOX,
...overrides,
});
const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: hasActiveFilters
? [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: [OTEL_DEMO, SAMPLE_FLASK],
},
]
: [],
},
},
],
},
},
redirectWithQueryBuilderData: jest.fn(),
});
describe('CheckboxFilter - User Flows', () => {
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Default mock implementations for useGetAggregateValues
mockUseGetAggregateValues.mockReturnValue({
data: {
payload: {
stringAttributeValues: MOCK_SERVICE_NAMES,
},
},
isLoading: false,
refetch: jest.fn(),
} as unknown as UseQueryResult<SuccessResponse<IAttributeValuesResponse>>);
// Default mock implementations for useGetQueryKeyValueSuggestions
// Returns data in the format expected by the hook
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
data: {
data: {
data: {
values: {
stringValues: MOCK_SERVICE_NAMES,
numberValues: [],
},
},
},
},
isLoading: false,
refetch: jest.fn(),
} as any);
// Setup MSW server for API calls
server.use(
rest.get('*/api/v3/autocomplete/attribute_values', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
});
it('should auto-open filter and prioritize checked items with visual separator when user opens page with active filters', async () => {
// Mock query builder with active filters
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// User should see the filter is automatically opened (not collapsed)
expect(screen.getByText('Service Name')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// User should see visual separator between checked and unchecked items
expect(screen.getByTestId('filter-separator')).toBeInTheDocument();
// User should see checked items at the top
await waitFor(() => {
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(4); // Ensure we have exactly 4 checkboxes
expect(checkboxes[0]).toBeChecked(); // otel-demo should be first and checked
expect(checkboxes[1]).toBeChecked(); // sample-flask should be second and checked
expect(checkboxes[2]).not.toBeChecked(); // mq-kafka should be unchecked
expect(checkboxes[3]).not.toBeChecked(); // otlp-python should be unchecked
});
});
it('should respect user preference when user manually toggles filter over auto-open behavior', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock query builder with active filters
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Initially auto-opened due to active filters
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// User manually closes the filter
await user.click(screen.getByText('Service Name'));
// User should see filter is now closed (respecting user preference)
expect(
screen.queryByPlaceholderText('Filter values'),
).not.toBeInTheDocument();
// User manually opens the filter again
await user.click(screen.getByText('Service Name'));
// User should see filter is now open (respecting user preference)
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
});
it('should update query filters when a checkbox is clicked', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Start with no active filters so clicking a checkbox creates one
mockUseQueryBuilder.mockReturnValue({
...createMockQueryBuilderData(false),
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// User unchecks the first value (`mq-kafka`)
await userEvent.click(checkboxes[0]);
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
expect(updatedFilters.items[0].op).toBe('not in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should set an IN filter with only the clicked value when using Only', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka', 'otel-demo'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for values to render
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Click on the value label to trigger the "Only" behavior
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
expect(updatedFilters.items[0].op).toBe('in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should clear filters for the attribute when using All', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const filtersForServiceName = updatedFilters.items.filter(
(item: any) => item.key?.key === SERVICE_NAME_KEY,
);
expect(filtersForServiceName).toHaveLength(0);
});
it('should match filter when query uses resource. prefix (resource.service.name matches service.name)', async () => {
// Filter config uses unprefixed key (service.name)
// Query has filter with resource. prefix (resource.service.name)
// Checkbox should recognize the match and show checked state
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: 'resource.service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: [OTEL_DEMO],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData: jest.fn(),
} as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Filter should auto-open because it has active filters (key match via prefix stripping)
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// otel-demo should be checked (filter uses resource.service.name IN [otel-demo])
// Checked items are sorted to the top, so otel-demo is first
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).toBeChecked();
expect(screen.getByText(OTEL_DEMO)).toBeInTheDocument();
});
it('should extend an existing IN filter when checking an additional value', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN 'mq-kafka'
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: 'mq-kafka',
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox corresponds to 'mq-kafka' (already selected),
// second will be 'otel-demo' which we now select additionally.
await userEvent.click(checkboxes[1]);
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const [filterForServiceName] = updatedFilters.items;
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
expect(filterForServiceName.op).toBe('in');
expect(filterForServiceName.value).toStrictEqual(['mq-kafka', 'otel-demo']);
});
});

View File

@@ -1,177 +0,0 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState, {
FILTER_DISABLED_REASON,
} from './useCheckboxFilterState';
import useCheckboxFilterValues from './useCheckboxFilterValues';
import './Checkbox.styles.scss';
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange, onQuickFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
const activeQueryIndex = useActiveQueryIndex(source);
const {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
const { attributeValues, isLoading } = useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
});
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
onQuickFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
(val) => currentFilterState[val],
);
const uncheckedValues = attributeValues.filter(
(val) => !currentFilterState[val],
);
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
}, [attributeValues, currentFilterState, visibleItemsCount]);
// Count of checked values in the currently visible items
const checkedValuesCount = useMemo(
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
[currentAttributeKeys, currentFilterState],
);
const isEmptyStateWithDocsEnabled =
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
!searchText &&
!attributeValues.length;
return (
<div className="checkbox-filter">
<CheckboxFilterHeader
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && (
<>
{!isEmptyStateWithDocsEnabled && (
<section className="search">
<Input
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
/>
</section>
)}
{attributeValues.length > 0 ? (
<section className="values">
{currentAttributeKeys.map((value: string, index: number) => (
<Fragment key={value}>
{index === checkedValuesCount && checkedValuesCount > 0 && (
<div
key="separator"
className="filter-separator"
data-testid="filter-separator"
/>
)}
<CheckboxValueRow
value={value}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
disabledTooltip={FILTER_DISABLED_REASON}
title={filter.title}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked): void => onChange(value, checked, false)}
onOnlyOrAllClick={(): void =>
onChange(value, currentFilterState[value], true)
}
/>
</Fragment>
))}
</section>
) : isEmptyStateWithDocsEnabled ? (
<LogsQuickFilterEmptyState attributeKey={filter.attributeKey.key} />
) : (
<section className="no-data">
<Typography.Text>No values found</Typography.Text>{' '}
</section>
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text className="show-more-text" onClick={onShowMore}>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}
CheckboxFilter.defaultProps = {
onFilterChange: null,
};

View File

@@ -1,47 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
}
function CheckboxFilterHeader({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section className="filter-header-checkbox" onClick={onToggleOpen}>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && showClearAll && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
);
}
export default CheckboxFilterHeader;

View File

@@ -1,77 +0,0 @@
import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Tooltip } from '@signozhq/ui/tooltip';
interface CheckboxValueRowProps {
value: string;
checked: boolean;
disabled: boolean;
disabledTooltip?: string;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean) => void;
onOnlyOrAllClick: () => void;
}
function CheckboxValueRow({
value,
checked,
disabled,
disabledTooltip,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
}: CheckboxValueRowProps): JSX.Element {
return (
<div className="value">
<Checkbox
color="primary"
disabledTooltip={disabledTooltip}
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
value={checked}
disabled={disabled}
/>
<div
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
>
<div className={`${title} label-${value}`} />
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Tooltip title={String(value)} side="top" align="start">
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
</Tooltip>
)}
<div className="value-actions">
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
</div>
);
}
CheckboxValueRow.defaultProps = {
disabledTooltip: undefined,
customRendererForValue: undefined,
};
export default CheckboxValueRow;

View File

@@ -1,53 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import EmptyQuickFilterIcon from 'assets/CustomIcons/EmptyQuickFilterIcon';
import { ArrowUpRight } from '@signozhq/icons';
const QUICK_FILTER_DOC_PATHS: Record<string, string> = {
severity_text: 'severity-text',
'deployment.environment': 'environment',
'service.name': 'service-name',
'host.name': 'hostname',
'k8s.cluster.name': 'k8s-cluster-name',
'k8s.deployment.name': 'k8s-deployment-name',
'k8s.namespace.name': 'k8s-namespace-name',
'k8s.pod.name': 'k8s-pod-name',
};
function LogsQuickFilterEmptyState({
attributeKey,
}: {
attributeKey: string;
}): JSX.Element {
const handleLearnMoreClick = (): void => {
const section = QUICK_FILTER_DOC_PATHS[attributeKey];
window.open(
`https://signoz.io/docs/logs-management/features/logs-quick-filters#${section}`,
'_blank',
);
};
return (
<section className="go-to-docs">
<div className="go-to-docs__container">
<div className="go-to-docs__container-icon">
<EmptyQuickFilterIcon />
</div>
<div className="go-to-docs__container-message">
{`You'd need to parse out this attribute to start getting them as a fast
filter.`}
</div>
</div>
<Button
type="link"
className="go-to-docs__button"
onClick={handleLearnMoreClick}
>
<div className="go-to-docs__button-text">Learn more</div>
<ArrowUpRight size={14} color={Color.BG_ROBIN_400} />
</Button>
</section>
);
}
export default LogsQuickFilterEmptyState;

View File

@@ -97,7 +97,7 @@ interface ToggleAction {
isOnlyOrAllClicked?: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
source?: QuickFiltersSource;
quickFilterSource?: QuickFiltersSource;
attributeValues?: string[];
}
@@ -117,7 +117,8 @@ function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
currentQuery: buildQuery(initialItems, initialExpression),
activeQueryIndex: 0,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
quickFilterSource:
c.action.quickFilterSource ?? QuickFiltersSource.LOGS_EXPLORER,
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
value: c.action.value,
checked: c.action.checked,
@@ -162,7 +163,7 @@ const TOGGLE_CASES: ToggleCase[] = [
action: {
value: 'a',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
},
// `nin` is what the source asks for, but re-deriving the expression
// normalises it. Nothing observes the difference: both infra pages send
@@ -313,7 +314,7 @@ const TOGGLE_CASES: ToggleCase[] = [
action: {
value: 'b',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
},
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],

View File

@@ -46,14 +46,18 @@ export function removeManagedClauses(expression: string, key: string): string {
}
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
const SOURCES_WITH_SHORT_OPERATORS: QuickFiltersSource[] = [
QuickFiltersSource.INFRA_MONITORING,
];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
export function getNotInOperator(
quickFilterSource: QuickFiltersSource,
): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(quickFilterSource)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
@@ -134,7 +138,7 @@ export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
quickFilterSource,
attributeValues,
value,
checked,
@@ -145,7 +149,7 @@ export function applyCheckboxToggle({
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
@@ -240,7 +244,7 @@ export function applyCheckboxToggle({
if (sectionType === SectionType.RELATED) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
op: getNotInOperator(quickFilterSource),
key: filter.attributeKey,
value,
};
@@ -380,7 +384,7 @@ export function applyCheckboxToggle({
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
op: getNotInOperator(quickFilterSource),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
@@ -404,7 +408,9 @@ export function applyCheckboxToggle({
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
const newFilterItem: TagFilterItem = {
id: uuid(),
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
op: checked
? getOperatorValue(OPERATORS.IN)
: getNotInOperator(quickFilterSource),
key: filter.attributeKey,
value,
};

View File

@@ -0,0 +1 @@
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;

View File

@@ -2,10 +2,9 @@ import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;

View File

@@ -14,7 +14,7 @@ import { SectionType } from './v2/itemRules';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
@@ -38,7 +38,7 @@ interface UseCheckboxFilterActionsReturn {
*/
function useCheckboxFilterActions({
filter,
source,
quickFilterSource,
attributeValues,
activeQueryIndex,
onFilterChange,
@@ -65,7 +65,7 @@ function useCheckboxFilterActions({
currentQuery,
activeQueryIndex,
filter,
source,
quickFilterSource,
attributeValues,
value,
checked,

View File

@@ -1,99 +0,0 @@
import { useMemo } from 'react';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface UseCheckboxFilterValuesProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
searchText: string;
isOpen: boolean;
}
interface UseCheckboxFilterValuesReturn {
attributeValues: string[];
isLoading: boolean;
}
function useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
return {
attributeValues,
isLoading: isLoading || isLoadingKeyValueSuggestions,
};
}
export default useCheckboxFilterValues;

View File

@@ -6,44 +6,6 @@
border-bottom: 1px solid var(--l1-border);
}
.sectionActions {
width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
width 0s linear 0.16s;
}
.checkboxFilter:hover .sectionActions,
.sectionActions.sectionActionsPinned {
width: auto;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sectionActionsPinned .sectionActionHoverOnly {
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);

View File

@@ -1,7 +1,6 @@
import { render, RenderResult } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -11,6 +10,7 @@ import {
QuickFiltersSource,
} from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
export const DEFAULT_FILTER: IQuickFiltersConfig = {
type: FiltersType.CHECKBOX,
@@ -20,7 +20,6 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
};
@@ -30,6 +29,13 @@ export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
existingQuery: null,
};
// Enough extra values to push a section past the count that shows its search box.
// Named to sort last, so a test's own values stay within the first page.
export const EXTRA_VALUES = Array.from(
{ length: DEFAULT_VISIBLE_ITEMS_COUNT },
(_, index) => `zz-extra-value-${index + 1}`,
);
export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
@@ -150,7 +156,7 @@ export function renderWithFilter(
return render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import classNames from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
@@ -17,6 +16,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';
@@ -33,7 +33,7 @@ import styles from './CheckboxFilterV2.module.scss';
interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
@@ -42,19 +42,18 @@ interface CheckboxFilterV2Props {
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const {
quickFilterSource,
filter,
onFilterChange,
onQuickFilterChange,
useFieldApis,
} = props;
const [searchText, setSearchText] = useState<string>('');
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const handleToggleSearch = (): void => {
setIsSearchOpen((prev) => !prev);
setSearchText('');
};
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
const {
isOpen,
@@ -81,7 +80,7 @@ export default function CheckboxFilterV2(
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
source,
quickFilterSource,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
@@ -110,7 +109,7 @@ export default function CheckboxFilterV2(
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
quickFilterSource,
attributeValues,
activeQueryIndex,
onFilterChange,
@@ -121,6 +120,14 @@ export default function CheckboxFilterV2(
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
const handleToggleOpen = (): void => {
if (isOpen) {
setSearchTextDebounced.cancel();
setSearchText('');
}
onToggleOpen();
};
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
@@ -166,19 +173,22 @@ export default function CheckboxFilterV2(
relatedExclusions,
});
// Kept while a search is typed, so narrowing the list does not remove the box,
// and while a refetch still shows the previous, narrowed results.
const wasSearchShown = useRef(false);
const isSearchShown =
searchText !== '' ||
totalCount > DEFAULT_VISIBLE_ITEMS_COUNT ||
(isFetching && wasSearchShown.current);
wasSearchShown.current = isSearchShown;
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
actionsClassName={classNames(styles.sectionActions, {
[styles.sectionActionsPinned]: isSearchOpen,
})}
resetActionClassName={
isSearchOpen ? styles.sectionActionHoverOnly : undefined
}
onToggleOpen={onToggleOpen}
onToggleSearch={handleToggleSearch}
canReset={isSomeFilterPresentForCurrentAttribute}
onToggleOpen={handleToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
@@ -188,10 +198,9 @@ export default function CheckboxFilterV2(
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
{isSearchOpen && (
{isSearchShown && (
<section className={styles.search}>
<Input
autoFocus
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}

View File

@@ -1,31 +1,25 @@
import { useState } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
import classNames from 'classnames';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
actionsClassName?: string;
resetActionClassName?: string;
canReset: boolean;
onToggleOpen: () => void;
onToggleSearch: () => void;
onClear: () => void;
}
export function CheckboxFilterV2Header({
title,
isOpen,
actionsClassName,
resetActionClassName,
canReset,
onToggleOpen,
onToggleSearch,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
@@ -64,21 +58,14 @@ export function CheckboxFilterV2Header({
)}
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
</section>
{isOpen && (
<section className={classNames(styles.rightAction, actionsClassName)}>
{isOpen && canReset && (
<section className={styles.rightAction}>
<SectionActionButton
icon={<Undo2 size={14} />}
className={resetActionClassName}
tooltip="Reset"
onClick={onClear}
testId="checkbox-filter-clear-all"
/>
<SectionActionButton
icon={<Search size={14} />}
tooltip="Search"
onClick={onToggleSearch}
testId="checkbox-filter-search-toggle"
/>
</section>
)}
</section>

View File

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -44,7 +44,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -59,6 +59,8 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
expect(params.get('endUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
);
expect(params.get('signal')).toBeNull();
expect(params.get('source')).toBeNull();
});
it('keeps non-AI sources on the signal-wide endpoint', async () => {
@@ -70,7 +72,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -50,7 +50,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'custom.query = "value"',
@@ -82,7 +82,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: null,
@@ -116,7 +116,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -154,7 +154,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -222,7 +222,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -264,7 +264,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,

View File

@@ -0,0 +1,48 @@
import { screen } from '@testing-library/react';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - field APIs', () => {
it('sends the signal and source of the quick filter source to the signal-wide endpoint', async () => {
const requests: URLSearchParams[] = [];
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: { values: { stringValues: ['ingest'] } },
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.METER_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('ingest')).resolves.toBeInTheDocument();
expect(requests[0].get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
expect(requests[0].get('source')).toBe(TelemetrytypesSourceDTO.meter);
});
});

View File

@@ -10,6 +10,7 @@ import {
buildQueryBuilderOverrides,
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
getFilterFromCall,
mockFieldsValuesAPI,
renderWithFilter,
@@ -18,6 +19,25 @@ import {
setupServer();
const activeEnvironmentFilter = buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
});
describe('CheckboxFilterV2 - interactions', () => {
describe('search functionality', () => {
it('filters values based on search text', async () => {
@@ -30,7 +50,7 @@ describe('CheckboxFilterV2 - interactions', () => {
const values =
searchTextReceived === ''
? ['production', 'staging', 'development']
? ['production', 'staging', 'development', ...EXTRA_VALUES]
: ['production'];
return res(
@@ -52,7 +72,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -60,7 +80,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -73,6 +92,41 @@ describe('CheckboxFilterV2 - interactions', () => {
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('checkbox-filter-search')).toBeInTheDocument();
});
it('shows no search box when every value fits', async () => {
mockFieldsValuesAPI({ stringValues: EXTRA_VALUES });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId(`checkbox-value-row-${EXTRA_VALUES[0]}`);
expect(
screen.queryByTestId('checkbox-filter-search'),
).not.toBeInTheDocument();
});
it('shows the search box once there are more values than fit', async () => {
mockFieldsValuesAPI({ stringValues: ['production', ...EXTRA_VALUES] });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(
screen.findByTestId('checkbox-filter-search'),
).resolves.toBeInTheDocument();
});
it('filters values via search while preserving existingQuery context', async () => {
@@ -92,7 +146,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: ['production'],
stringValues: ['staging', 'development'],
stringValues: ['staging', 'development', ...EXTRA_VALUES],
numberValues: [],
},
},
@@ -119,7 +173,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -144,7 +198,6 @@ describe('CheckboxFilterV2 - interactions', () => {
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -172,7 +225,10 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
stringValues:
searchText === ''
? ['prod', 'staging', ...EXTRA_VALUES]
: ['prod-match'],
numberValues: [],
},
},
@@ -184,7 +240,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -194,7 +250,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -220,7 +275,8 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : [],
stringValues:
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
numberValues: [],
},
},
@@ -232,14 +288,13 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
@@ -275,7 +330,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -316,7 +371,9 @@ describe('CheckboxFilterV2 - interactions', () => {
const relatedValues =
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
const stringValues =
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
searchText === ''
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
: ['pod-a-v2']; // filtered to match 'pod-a'
return res(
ctx.status(200),
@@ -337,7 +394,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -347,7 +404,6 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-pod-a-v1');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
@@ -384,7 +440,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -412,7 +468,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -425,6 +481,113 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
it('opens a closed filter when the query filters on its key', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{ queryBuilderOverrides: activeEnvironmentFilter },
);
expect(screen.getByTestId('checkbox-filter-header')).toHaveAttribute(
'data-state',
'open',
);
await expect(
screen.findByTestId('checkbox-value-row-production'),
).resolves.toHaveAttribute('data-state', 'checked');
});
it('keeps the user toggle over the auto-open', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{ queryBuilderOverrides: activeEnvironmentFilter },
);
const header = screen.getByTestId('checkbox-filter-header');
await screen.findByTestId('checkbox-value-row-production');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'closed');
expect(
screen.queryByTestId('checkbox-value-row-production'),
).not.toBeInTheDocument();
await user.click(header);
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
it('clears the search when the filter is collapsed', async () => {
const user = userEvent.setup();
const values = Array.from(
{ length: 12 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: values.filter((value) => value.includes(searchText)),
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
await user.type(screen.getByTestId('checkbox-filter-search'), '11');
await waitFor(() =>
expect(
screen.queryByTestId('checkbox-value-row-value-00'),
).not.toBeInTheDocument(),
);
const header = screen.getByTestId('checkbox-filter-header');
await user.click(header);
await user.click(header);
await screen.findByTestId('checkbox-value-row-value-00');
expect(screen.getByTestId('checkbox-filter-search')).toHaveValue('');
});
});
describe('show more functionality', () => {
@@ -438,7 +601,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -464,7 +627,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -489,7 +652,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -520,7 +683,7 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('shows the reset action when expanded even with no active filter', async () => {
it('hides reset when the section has no filter applied', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
@@ -528,52 +691,16 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
// Reset is always available on an expanded section now (hover-gated via
// CSS), not conditional on an active filter.
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('does not dispatch on clear when the key has no filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: '' },
},
],
},
}),
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).not.toHaveBeenCalled();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('calls onFilterChange when clear clicked', async () => {
@@ -587,7 +714,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -634,7 +761,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -710,7 +837,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -844,7 +971,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -55,7 +55,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -106,7 +106,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -140,7 +140,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -221,7 +221,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -273,7 +273,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -321,7 +321,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -373,7 +373,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -424,7 +424,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -466,7 +466,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -521,7 +521,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',

View File

@@ -9,6 +9,7 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
mockFieldsValuesAPI,
mockFieldsValuesAPILoading,
setupServer,
@@ -24,7 +25,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -46,7 +47,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={closedFilter}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -89,7 +90,7 @@ describe('CheckboxFilterV2 - states', () => {
data: {
values: {
relatedValues: [],
stringValues: ['production', 'staging'],
stringValues: ['production', 'staging', ...EXTRA_VALUES],
numberValues: [],
},
},
@@ -103,14 +104,13 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -133,7 +133,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -152,7 +152,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -172,7 +172,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -195,7 +195,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
canReset: true,
onToggleOpen: jest.fn(),
onToggleSearch: jest.fn(),
onClear: jest.fn(),
};
@@ -30,12 +30,9 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not render the section actions when collapsed', () => {
it('does not render reset when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(
screen.queryByTestId('checkbox-filter-search-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
@@ -50,14 +47,19 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'open');
});
it('renders both search and reset actions when expanded', () => {
it('renders reset when the section has a filter applied', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
expect(
screen.getByTestId('checkbox-filter-search-toggle'),
).toBeInTheDocument();
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides reset when there is nothing to reset', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen canReset={false} />);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
describe('interactions', () => {
@@ -99,25 +101,6 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleSearch on search click without toggling open', async () => {
const user = userEvent.setup();
const onToggleSearch = jest.fn();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleSearch={onToggleSearch}
onToggleOpen={onToggleOpen}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
expect(onToggleSearch).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
it('calls onClear on reset click without toggling open', async () => {
const user = userEvent.setup();
const onClear = jest.fn();

View File

@@ -1,20 +1,20 @@
import { useMemo } from 'react';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldValuesConfig } from 'api/querySuggestions/types';
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
filter: IQuickFiltersConfig;
searchText: string;
existingQuery?: string;
metricNamespace?: string;
source?: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
@@ -38,12 +38,13 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
source,
quickFilterSource,
startUnixMilli,
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const isAIObservability =
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
@@ -59,14 +60,12 @@ export function useFieldValues({
endUnixMilli,
}
: {
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[quickFilterSource],
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
source: QUICK_FILTERS_SOURCE_TO_SOURCE[quickFilterSource],
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future

View File

@@ -31,11 +31,11 @@ export type { FilterType };
function Duration({
filter,
onFilterChange,
source,
quickFilterSource,
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
source: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,7 +52,7 @@ function Duration({
const compositeQuery = useGetCompositeQueryParam();
const activeQueryIndex = useActiveQueryIndex(source);
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -34,7 +34,6 @@ import {
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
@@ -50,15 +49,18 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
className,
config,
handleFilterVisibilityChange,
source,
quickFilterSource,
onFilterChange,
onQuickFilterChange,
signal,
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const savedFiltersSource =
quickFilterSource === QuickFiltersSource.INFRA_MONITORING
? undefined
: quickFilterSource;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
@@ -69,7 +71,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
customFilters,
refetchCustomFilters,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
} = useFilterConfig({ savedFiltersSource, config });
const {
deniedPermissions: deniedSettingsPermissions,
isLoading: isSettingsChecking,
@@ -113,15 +115,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Show dropdown in ListView only for TRACES_EXPLORER source
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
isListView && quickFilterSource === QuickFiltersSource.TRACES_EXPLORER;
// AI observability builds a single query in the row-level views, so there is
// no query for the selector to switch between.
const isAIObservabilityRowView =
source === QuickFiltersSource.AI_OBSERVABILITY &&
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(source);
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -291,7 +293,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderContent = (): JSX.Element => (
<>
{source === QuickFiltersSource.API_MONITORING && (
{quickFilterSource === QuickFiltersSource.API_MONITORING && (
<div className="api-quick-filters-header">
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
@@ -310,55 +312,27 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<section className="filters">
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
case FiltersType.DURATION:
return (
<Duration
key={filter.attributeKey.key}
source={source}
quickFilterSource={quickFilterSource}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider key={filter.attributeKey.key} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return useFieldApis ? (
return (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
quickFilterSource={quickFilterSource}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
}
})}
@@ -392,14 +366,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
{quickFilterSource !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}
{renderRightActions()}
</section>
)}
{signal ? (
{savedFiltersSource ? (
<AuthZGuardContent
checks={[QuickFilterReadPermission]}
fallbackOnLoading={filtersSkeleton}
@@ -420,9 +394,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
className,
)}
>
{isSettingsOpen && (
{isSettingsOpen && savedFiltersSource && (
<QuickFiltersSettings
signal={signal}
savedFiltersSource={savedFiltersSource}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
refetchCustomFilters={refetchCustomFilters}
@@ -436,9 +410,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
showFilterCollapse: true,
showQueryName: true,
useFieldApis: undefined,
};

View File

@@ -9,6 +9,7 @@ import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
import { useCloseOnOutsidePress } from './useCloseOnOutsidePress';
import styles from './QuickFiltersLayout.module.scss';
@@ -61,12 +62,20 @@ function QuickFiltersLayout({
const isSliding = hasQuickFilters && isViewsListOpen;
const openViews = (): void => setIsViewsListOpen(true);
const toggleViews = (): void => setIsViewsListOpen((isOpen) => !isOpen);
const closeViews = (): void => setIsViewsListOpen(false);
const markSidebarPress = useCloseOnOutsidePress(
showSidebar && isSliding,
closeViews,
);
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
onOpenViews={isViewsListVisible ? undefined : openViews}
isViewsOpen={isViewsListOpen}
onToggleViews={isViewsListPinned ? undefined : toggleViews}
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
/>
) : undefined;
@@ -77,6 +86,7 @@ function QuickFiltersLayout({
{showSidebar && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
onPointerDownCapture={markSidebarPress}
data-testid="quick-filters-layout-filters"
>
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
@@ -98,9 +108,7 @@ function QuickFiltersLayout({
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
onClose={isViewsListPinned ? undefined : closeViews}
/>
)}
</div>

View File

@@ -34,17 +34,17 @@ jest.mock('container/SavedViews/SavedViewsRestore', () => ({
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
source,
quickFilterSource,
savedViewsHeader,
handleFilterVisibilityChange,
}: {
source: string;
quickFilterSource: string;
savedViewsHeader?: React.ReactNode;
handleFilterVisibilityChange?: () => void;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
{quickFilterSource}
{handleFilterVisibilityChange && (
<Button
size="md"
@@ -62,7 +62,8 @@ jest.mock('../../QuickFilters', () => ({
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
@@ -217,6 +218,62 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('opens and closes the panel from the view name', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const toggle = await screen.findByTestId('saved-views-name-toggle');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await user.click(toggle);
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
await user.click(toggle);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
it('closes the panel on a click outside the sidebar, not inside it', async () => {
render(
<>
<button type="button" data-testid="page-tab">
Pipelines
</button>
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div data-testid="page-content">content</div>
</QuickFiltersLayout>
</>,
);
const user = userEvent.setup();
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('saved-views-panel-header'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
await user.click(screen.getByTestId('page-tab'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('page-content'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
@@ -257,6 +314,9 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-panel-header'),
).not.toBeInTheDocument();

View File

@@ -0,0 +1,63 @@
import { createPortal } from 'react-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
function Harness({
enabled,
onClose,
}: {
enabled: boolean;
onClose: () => void;
}): JSX.Element {
const markInside = useCloseOnOutsidePress(enabled, onClose);
return (
<>
<div onPointerDownCapture={markInside}>
<button type="button" data-testid="inside">
inside
</button>
{createPortal(
<button type="button" data-testid="portal">
menu item
</button>,
document.body,
)}
</div>
<button type="button" data-testid="outside">
outside
</button>
</>
);
}
describe('useCloseOnOutsidePress', () => {
it('closes on a press outside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open on a press inside, including a portal rendered from inside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside'));
await userEvent.click(screen.getByTestId('portal'));
expect(onClose).not.toHaveBeenCalled();
});
it('does nothing while disabled', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled={false} onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Calls `onClose` on any press outside the element given the returned handler as
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
* inside: React events follow the component tree, so their presses reach the
* handler before the document listener runs.
*/
export function useCloseOnOutsidePress(
enabled: boolean,
onClose: () => void,
): () => void {
const isPressInside = useRef(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!enabled) {
return undefined;
}
isPressInside.current = false;
const handlePointerDown = (): void => {
if (isPressInside.current) {
isPressInside.current = false;
return;
}
onCloseRef.current();
};
document.addEventListener('pointerdown', handlePointerDown);
return (): void =>
document.removeEventListener('pointerdown', handlePointerDown);
}, [enabled]);
return useCallback((): void => {
isPressInside.current = true;
}, []);
}

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
QuickfiltertypesSourceDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
@@ -13,7 +15,6 @@ import {
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -32,18 +33,20 @@ function OtherFiltersSkeleton(): JSX.Element {
}
function OtherFilters({
signal,
savedFiltersSource,
inputValue,
addedFilters,
setAddedFilters,
}: {
signal: SignalType | undefined;
savedFiltersSource: QuickfiltertypesSourceDTO;
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const isMeterDataSource =
savedFiltersSource === QuickfiltertypesSourceDTO.meter;
const isAIObservability =
savedFiltersSource === QuickfiltertypesSourceDTO.ai_observability;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
@@ -53,9 +56,7 @@ function OtherFilters({
? { searchText: inputValue }
: {
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[savedFiltersSource],
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};

View File

@@ -2,9 +2,9 @@ import { Button as SignozButton } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
import useQuickFilterSettings from './hooks/useQuickFilterSettings';
import OtherFilters from './OtherFilters';
@@ -12,12 +12,12 @@ import OtherFilters from './OtherFilters';
import './QuickFiltersSettings.styles.scss';
function QuickFiltersSettings({
signal,
savedFiltersSource,
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
}: {
signal: SignalType | undefined;
savedFiltersSource: QuickfiltertypesSourceDTO;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
@@ -37,7 +37,7 @@ function QuickFiltersSettings({
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
signal,
savedFiltersSource,
});
return (
@@ -73,7 +73,7 @@ function QuickFiltersSettings({
setAddedFilters={setAddedFilters}
/>
<OtherFilters
signal={signal}
savedFiltersSource={savedFiltersSource}
inputValue={debouncedInputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}

View File

@@ -3,8 +3,8 @@ import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render } from 'tests/test-utils';
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SignalType } from '../../types';
import OtherFilters from '../OtherFilters';
const BASE_URL = ENVIRONMENT.baseURL;
@@ -43,10 +43,12 @@ describe('OtherFilters - AI observability keys', () => {
);
});
function renderOtherFilters(signal: SignalType): void {
function renderOtherFilters(
savedFiltersSource: QuickfiltertypesSourceDTO,
): void {
render(
<OtherFilters
signal={signal}
savedFiltersSource={savedFiltersSource}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
@@ -55,7 +57,7 @@ describe('OtherFilters - AI observability keys', () => {
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
await expect(
screen.findByText('gen_ai.request.model'),
@@ -64,7 +66,7 @@ describe('OtherFilters - AI observability keys', () => {
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
@@ -73,7 +75,7 @@ describe('OtherFilters - AI observability keys', () => {
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(SignalType.TRACES);
renderOtherFilters(QuickfiltertypesSourceDTO.traces);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());

View File

@@ -1,11 +0,0 @@
import { SignalType } from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.LOGS]: DataSource.LOGS,
[SignalType.TRACES]: DataSource.TRACES,
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
[SignalType.METER_EXPLORER]: DataSource.METRICS,
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
};

View File

@@ -4,8 +4,8 @@ import logEvent from 'api/common/logEvent';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
QuickfiltertypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
@@ -16,7 +16,7 @@ interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
signal?: SignalType;
savedFiltersSource: QuickfiltertypesSourceDTO;
}
interface UseQuickFilterSettingsReturn {
@@ -37,7 +37,7 @@ const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
refetchCustomFilters,
signal,
savedFiltersSource,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
@@ -113,21 +113,19 @@ const useQuickFilterSettings = ({
);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
pathParams: { source: signal },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
},
});
}
}, [addedFilters, signal, updateCustomFilters]);
updateCustomFilters({
pathParams: { source: savedFiltersSource },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
},
});
}, [addedFilters, savedFiltersSource, updateCustomFilters]);
return {
handleSettingsClose,

View File

@@ -0,0 +1,16 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { QuickFiltersSource } from './types';
export const QUICK_FILTER_SOURCE_TO_SIGNAL: Record<
QuickFiltersSource,
TelemetrytypesSignalDTO
> = {
[QuickFiltersSource.LOGS_EXPLORER]: TelemetrytypesSignalDTO.logs,
[QuickFiltersSource.TRACES_EXPLORER]: TelemetrytypesSignalDTO.traces,
[QuickFiltersSource.EXCEPTIONS]: TelemetrytypesSignalDTO.traces,
[QuickFiltersSource.API_MONITORING]: TelemetrytypesSignalDTO.traces,
[QuickFiltersSource.AI_OBSERVABILITY]: TelemetrytypesSignalDTO.traces,
[QuickFiltersSource.METER_EXPLORER]: TelemetrytypesSignalDTO.metrics,
[QuickFiltersSource.INFRA_MONITORING]: TelemetrytypesSignalDTO.metrics,
};

View File

@@ -10,26 +10,26 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
* mode track the last focused query.
*/
function useActiveQueryIndex(source: QuickFiltersSource): number {
function useActiveQueryIndex(quickFilterSource: QuickFiltersSource): number {
const { lastUsedQuery, panelType } = useQueryBuilder();
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
// AI observability builds a single query in the row-level views, so its
// filters always drive the first one there.
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
if (quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
return quickFilterSource === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, panelType, source, lastUsedQuery]);
}, [isListView, panelType, quickFilterSource, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { IQuickFiltersConfig, SignalType } from '../types';
import { IQuickFiltersConfig } from '../types';
import { getFilterConfig } from '../utils';
interface UseFilterConfigProps {
signal?: SignalType;
savedFiltersSource?: QuickfiltertypesSourceDTO;
config: IQuickFiltersConfig[];
}
interface UseFilterConfigReturn {
@@ -18,7 +19,7 @@ interface UseFilterConfigReturn {
}
const useFilterConfig = ({
signal,
savedFiltersSource,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
@@ -26,8 +27,8 @@ const useFilterConfig = ({
data,
refetch,
} = useGetQuickFilters(
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
{ source: savedFiltersSource ?? '' },
{ query: { enabled: !!savedFiltersSource } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
@@ -35,11 +36,11 @@ const useFilterConfig = ({
[data],
);
const isDynamicFilters = !!signal;
const isDynamicFilters = !!savedFiltersSource;
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal],
() => getFilterConfig(customFilters, config),
[config, customFilters],
);
return {

View File

@@ -11,11 +11,13 @@ import {
import { rest, type RequestHandler } from 'msw';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { attributeValuesResponse } from '@/storybook/msw/__story_mockdata__/attributes';
import { fieldKeysResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
import { FiltersType } from '../types';
import { FiltersType, QuickFilterCheckboxUseFieldApis } from '../types';
const customFilters = [
{
@@ -61,13 +63,15 @@ export const checkboxConfig = [
},
];
export const attributeValuesHandler = (
values: readonly string[],
): RequestHandler =>
rest.get(
'http://localhost/api/v3/autocomplete/attribute_values',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(attributeValuesResponse(values))),
export const fieldApis: QuickFilterCheckboxUseFieldApis = {
startUnixMilli: 1700000000000,
endUnixMilli: 1700003600000,
existingQuery: null,
};
export const fieldValuesHandler = (values: readonly string[]): RequestHandler =>
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldValuesResponse(values))),
);
export const handlers = [
@@ -82,9 +86,18 @@ export const handlers = [
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldKeysResponse(['k8s.namespace.name']))),
),
attributeValuesHandler(['checkout', 'frontend', 'payments']),
fieldValuesHandler(['checkout', 'frontend', 'payments']),
];
export const noSavedFiltersHandler = rest.get(
'http://localhost/api/v2/quick_filters/logs',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.logs, [])),
),
);
export const loadingFiltersHandlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(ctx.delay('infinite')),

View File

@@ -7,15 +7,17 @@ import type { GlobalMockArgs } from '@/storybook/globals';
import QuickFilters from '../QuickFilters';
import {
attributeValuesHandler,
checkboxConfig,
fieldApis,
fieldValuesHandler,
handlers,
LONG_FILTER_VALUES,
loadingFiltersHandlers,
queryBuilder,
selectedServiceQueryBuilder,
noSavedFiltersHandler,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersSource } from '../types';
const meta = {
title: 'Components/Quick Filters',
@@ -32,8 +34,8 @@ const meta = {
args: {
config: checkboxConfig,
handleFilterVisibilityChange: (): void => undefined,
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
quickFilterSource: QuickFiltersSource.LOGS_EXPLORER,
useFieldApis: fieldApis,
},
parameters: {
msw: { handlers },
@@ -92,13 +94,16 @@ export const LoadingFilters: Story = {
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
export const NoResults: Story = {
args: { config: [], signal: undefined },
args: { config: [] },
parameters: {
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
},
};
/** Selection: an expanded checkbox shows the actual selected service values. */
export const SelectedExpandedCheckbox: Story = {
args: { signal: undefined },
parameters: {
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
signoz: {
queryBuilder: selectedServiceQueryBuilder,
},
@@ -109,12 +114,18 @@ export const SelectedExpandedCheckbox: Story = {
* Every tooltip the panel renders, held open: the reveal on each truncated
* filter value. Nothing bounds those values, so the panel is answered with
* service names long enough to be cut. The Service name filter carries them, so
* the signal that would add the workspace's own dynamic filters is left off.
* the workspace's own saved filter list is answered empty.
*/
export const Tooltips: TooltipsStory = {
args: { signal: undefined, tooltipsOpen: true },
args: { tooltipsOpen: true },
parameters: {
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
msw: {
handlers: [
noSavedFiltersHandler,
fieldValuesHandler(LONG_FILTER_VALUES),
...handlers,
],
},
},
};

View File

@@ -16,7 +16,7 @@ import {
} from 'lib/authz/utils/authz-test-utils';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersFieldValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
@@ -24,8 +24,8 @@ import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
import { QuickFiltersSource } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -35,10 +35,8 @@ jest.mock('container/ApiMonitoring/queryParams');
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
const attributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const NOT_AUTHORIZED_TEXT = /is not authorized to perform/i;
@@ -71,11 +69,8 @@ beforeEach(() => {
rest.get(fieldsKeysURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.get(attributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
),
);
});
@@ -85,12 +80,12 @@ afterEach(() => {
jest.clearAllMocks();
});
function renderWithSignal(): void {
function renderWithSavedList(): void {
render(
<QuickFilters
source={QuickFiltersSource.LOGS_EXPLORER}
signal={SIGNAL}
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
/>,
);
}
@@ -98,9 +93,10 @@ function renderWithSignal(): void {
function renderStaticConfig(): void {
render(
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
/>,
);
}
@@ -110,7 +106,7 @@ describe('QuickFilters - AuthZ', () => {
it('shows the inline denial instead of the filters, header stays', async () => {
server.use(setupAuthzDeny(QuickFilterReadPermission));
renderWithSignal();
renderWithSavedList();
await expect(
screen.findByText(NOT_AUTHORIZED_TEXT),
@@ -135,7 +131,7 @@ describe('QuickFilters - AuthZ', () => {
it('renders the filters but disables the settings trigger', async () => {
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
renderWithSignal();
renderWithSavedList();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -160,7 +156,7 @@ describe('QuickFilters - AuthZ', () => {
it('renders the filters and opens settings from the trigger', async () => {
server.use(setupAuthzAdmin());
renderWithSignal();
renderWithSavedList();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -178,7 +174,7 @@ describe('QuickFilters - AuthZ', () => {
});
});
describe('static config pages (no signal)', () => {
describe('static config pages (no saved list)', () => {
it('is not gated even when every permission is denied', async () => {
server.use(setupAuthzDenyAll());
@@ -197,7 +193,7 @@ describe('QuickFilters - AuthZ', () => {
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderWithSignal();
renderWithSavedList();
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access

View File

@@ -1,12 +1,15 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import {
ApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { getHostsQuickFiltersConfig } from 'container/InfraMonitoringHostsV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersFieldValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
@@ -16,8 +19,8 @@ import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
import { IQuickFiltersConfig, QuickFiltersSource } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -33,11 +36,10 @@ const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/logs`;
const anyQuickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/:source`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const FILTER_OS_DESCRIPTION = 'os.description';
@@ -55,6 +57,9 @@ const setupServer = (): void => {
rest.get(quickFiltersListURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.get(anyQuickFiltersListURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { filters: [] } })),
),
rest.get(quickFiltersSuggestionsURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
@@ -62,56 +67,31 @@ const setupServer = (): void => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
),
);
};
function TestQuickFilters({
signal = SignalType.LOGS,
quickFilterSource = QuickFiltersSource.EXCEPTIONS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
quickFilterSource?: QuickFiltersSource;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
quickFilterSource={quickFilterSource}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
useFieldApis={QuickFiltersFieldApis}
/>
);
}
TestQuickFilters.defaultProps = {
signal: '',
config: QuickFiltersConfig,
};
function TestQuickFiltersApiMonitoring({
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
source={QuickFiltersSource.API_MONITORING}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
);
}
TestQuickFiltersApiMonitoring.defaultProps = {
signal: '',
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
config: QuickFiltersConfig,
};
@@ -289,10 +269,14 @@ describe('Quick Filters', () => {
it('toggles Show IP addresses and updates API Monitoring params', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFiltersApiMonitoring />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.API_MONITORING} />,
);
// Switch should be rendered and initially checked
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
await expect(
screen.findByText('Show IP addresses'),
).resolves.toBeInTheDocument();
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
@@ -310,7 +294,9 @@ describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
@@ -370,7 +356,9 @@ describe('Quick Filters with custom filters', () => {
),
);
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -398,7 +386,9 @@ describe('Quick Filters with custom filters', () => {
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -419,7 +409,9 @@ describe('Quick Filters with custom filters', () => {
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -448,7 +440,9 @@ describe('Quick Filters with custom filters', () => {
it('restores original filter state on Discard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -490,7 +484,9 @@ describe('Quick Filters with custom filters', () => {
it('saves the updated filters by calling PUT with correct payload', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -527,7 +523,9 @@ describe('Quick Filters with custom filters', () => {
pointerEventsCheck: 0,
});
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
const { getByTestId } = render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await screen.findByText(FILTER_SERVICE_NAME);
expect(screen.getByText('Duration')).toBeInTheDocument();
@@ -581,7 +579,7 @@ describe('Quick Filters with custom filters', () => {
});
describe('Quick Filters refetch behavior', () => {
it('fetches custom filters on every mount when signal is provided', async () => {
it('fetches custom filters on every mount for a page with a saved list', async () => {
let getCalls = 0;
server.use(
@@ -591,14 +589,18 @@ describe('Quick Filters refetch behavior', () => {
}),
);
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
const { unmount } = render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
unmount();
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
@@ -606,19 +608,24 @@ describe('Quick Filters refetch behavior', () => {
expect(getCalls).toBe(2);
});
it('does not fetch custom filters when signal is undefined', async () => {
it('does not fetch custom filters for a page without a saved list', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
rest.get(anyQuickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
render(<TestQuickFilters signal={undefined} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.INFRA_MONITORING} />,
);
await waitFor(() => expect(getCalls).toBe(0));
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
expect(getCalls).toBe(0);
});
it('refetches custom filters after saving settings', async () => {
@@ -637,7 +644,9 @@ describe('Quick Filters refetch behavior', () => {
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -689,7 +698,9 @@ describe('Quick Filters refetch behavior', () => {
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -720,10 +731,47 @@ describe('Quick Filters refetch behavior', () => {
),
);
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
render(
<TestQuickFilters
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
config={[]}
/>,
);
await expect(
screen.findByText('No filters found'),
).resolves.toBeInTheDocument();
});
});
describe('Quick Filters field APIs', () => {
it('sends the page signal for every filter, the infra Environment filter included', async () => {
const requests: URLSearchParams[] = [];
server.use(
rest.get(fieldsValuesURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse));
}),
);
render(
<QuickFilters
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig()}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={QuickFiltersFieldApis}
/>,
);
await waitFor(() =>
expect(requests.map((params) => params.get('name'))).toContain(
INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
),
);
const environment = requests.find(
(params) =>
params.get('name') === INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
);
expect(environment?.get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
});
});

View File

@@ -1,6 +1,6 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FiltersType } from '../types';
import { FiltersType, QuickFilterCheckboxUseFieldApis } from '../types';
export const QuickFiltersConfig = [
{
@@ -24,3 +24,9 @@ export const QuickFiltersConfig = [
defaultOpen: false,
},
];
export const QuickFiltersFieldApis: QuickFilterCheckboxUseFieldApis = {
startUnixMilli: 1700000000000,
endUnixMilli: 1700003600000,
existingQuery: null,
};

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export enum FiltersType {
SLIDER = 'SLIDER',
@@ -19,15 +19,6 @@ export enum SpecficFilterOperations {
ONLY = 'ONLY',
}
export enum SignalType {
TRACES = 'traces',
LOGS = 'logs',
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai_observability',
}
/**
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
*/
@@ -39,7 +30,6 @@ export interface IQuickFiltersConfig {
attributeKey: BaseAutocompleteData;
aggregateOperator?: string;
aggregateAttribute?: string;
dataSource?: DataSource;
customRendererForValue?: (value: string) => JSX.Element;
defaultOpen: boolean;
}
@@ -53,31 +43,30 @@ export interface QuickFilterChangeEventData {
export interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange?: () => void;
source: QuickFiltersSource;
quickFilterSource: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
/** Pass to fetch quick filters for this signal; omit to use `config` as-is */
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
useFieldApis: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {
LOGS_EXPLORER = 'logs-explorer',
INFRA_MONITORING = 'infra-monitoring',
TRACES_EXPLORER = 'traces-explorer',
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai-observability',
}
export const QuickFiltersSource = {
LOGS_EXPLORER: QuickfiltertypesSourceDTO.logs,
TRACES_EXPLORER: QuickfiltertypesSourceDTO.traces,
EXCEPTIONS: QuickfiltertypesSourceDTO.exceptions,
API_MONITORING: QuickfiltertypesSourceDTO.api_monitoring,
METER_EXPLORER: QuickfiltertypesSourceDTO.meter,
AI_OBSERVABILITY: QuickfiltertypesSourceDTO.ai_observability,
/** No saved filter list: renders `config` as given. */
INFRA_MONITORING: 'infra-monitoring',
} as const;
export type QuickFiltersSource =
(typeof QuickFiltersSource)[keyof typeof QuickFiltersSource];
/**
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
*/
export type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: number;
endUnixMilli: number;

View File

@@ -1,9 +1,8 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
import { FiltersType, IQuickFiltersConfig } from './types';
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
@@ -50,11 +49,10 @@ const getFilterType = (att: TelemetryFieldKey): FiltersType => {
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
if (!customFilters?.length) {
return config || [];
}
@@ -63,7 +61,6 @@ export const getFilterConfig = (
({
type: getFilterType(att),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.name,
key: att.name,

View File

@@ -2,6 +2,7 @@ export const GlobalShortcuts = {
NavigateToServices: 'shift+s',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -23,7 +24,6 @@ export const GlobalShortcuts = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -34,6 +34,7 @@ export const GlobalShortcutsName = {
NavigateToTraces: 'shift+t',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -44,7 +45,6 @@ export const GlobalShortcutsName = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -59,6 +59,7 @@ export const GlobalShortcutsDescription = {
NavigateToLogs: 'Navigate to Logs Explorer',
NavigateToDashboards: 'Navigate to Dashboards List',
NavigateToAlerts: 'Navigate to Alerts List',
NavigateToNotificationChannels: 'Navigate to Notification Channels',
NavigateToExceptions: 'Navigate to Exceptions List',
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
@@ -68,8 +69,6 @@ export const GlobalShortcutsDescription = {
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',

View File

@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
BottomStrip: 'bottom_strip',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -334,6 +334,7 @@ function AnomalyAlertEvaluationView({
<Checkbox
color="primary"
key={seriesKey}
testId="anomaly-series-option"
name="series"
value={selectedSeries === seriesKey}
onChange={(): void => handleSeriesChange(seriesKey)}

View File

@@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import DomainList from './Domains/DomainList';
@@ -24,8 +24,7 @@ function Explorer(): JSX.Element {
showFilters
quickFilterProps={{
className: 'qf-api-monitoring',
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
quickFilterSource: QuickFiltersSource.API_MONITORING,
showQueryName: false,
useFieldApis: quickFilterFieldApis,
}}

View File

@@ -91,7 +91,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
{
@@ -102,7 +101,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
{
@@ -113,7 +111,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
];

View File

@@ -1,8 +1,15 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
@@ -37,6 +44,14 @@ function AskNoz(): JSX.Element | null {
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.BottomStrip,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
color="secondary"
size="sm"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
onClick={handleOpen}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${

View File

@@ -1,4 +1,9 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
@@ -6,6 +11,10 @@ import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
@@ -91,6 +100,17 @@ describe('AskNoz', () => {
expect(mockOpen).toHaveBeenCalledTimes(1);
});
it('reports the open as coming from the strip', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(logEvent).toHaveBeenCalledWith(
AIAssistantEvents.Opened,
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
);
});
});
describe('pending badge', () => {

View File

@@ -1,9 +1,16 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
import {
BottomStripEventKeys,
BottomStripEvents,
BottomStripSupportType,
} from '../events';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
const needsCard = chatSupport === ChatSupportState.NeedsCard;
void logEvent(BottomStripEvents.SupportClicked, {
[BottomStripEventKeys.Type]: needsCard
? BottomStripSupportType.AddCard
: BottomStripSupportType.Pylon,
});
if (needsCard) {
setIsAddCreditCardModalOpen(true);
return;
}

View File

@@ -1,9 +1,16 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import { BottomStripEvents, BottomStripSupportType } from '../../events';
import SupportButton from '../SupportButton';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
@@ -37,6 +44,9 @@ describe('SupportButton', () => {
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.Pylon,
});
});
it('does not open the credit card modal', () => {
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.AddCard,
});
});
});

View File

@@ -0,0 +1,12 @@
export enum BottomStripEvents {
SupportClicked = 'Bottom Strip: Support clicked',
}
export enum BottomStripEventKeys {
Type = 'type',
}
export enum BottomStripSupportType {
Pylon = 'pylon',
AddCard = 'add_card',
}

View File

@@ -7,11 +7,12 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup guide button only exists in the classic form.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
}),
}));
@@ -20,7 +21,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
hash: '',
state: null,
})),

View File

@@ -1,5 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import {
LicensePlatform,
LicenseResModel,
} from 'types/api/licensesV3/getActive';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -8,6 +11,10 @@ import SelectAlertType from '..';
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const CLOUD_LICENSE = {
platform: LicensePlatform.CLOUD,
} as LicenseResModel;
describe('SelectAlertType', () => {
const mockOnSelect = jest.fn();
@@ -19,15 +26,7 @@ describe('SelectAlertType', () => {
it('should render all alert type options when anomaly detection is enabled', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
activeLicense: CLOUD_LICENSE,
});
render(<SelectAlertType onSelect={mockOnSelect} />);
@@ -39,6 +38,19 @@ describe('SelectAlertType', () => {
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
});
it('should offer the anomaly based alert on an enterprise self-hosted license', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
activeLicense: {
platform: LicensePlatform.SELF_HOSTED,
} as LicenseResModel,
});
render(<SelectAlertType onSelect={mockOnSelect} />);
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
});
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
render(<SelectAlertType onSelect={mockOnSelect} />);
@@ -62,15 +74,7 @@ describe('SelectAlertType', () => {
it('should call onSelect with anomaly based alert type', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
activeLicense: CLOUD_LICENSE,
});
render(<SelectAlertType onSelect={mockOnSelect} />);

View File

@@ -5,8 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -17,11 +16,8 @@ import { OptionType } from './types';
function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
const { t } = useTranslation(['alerts']);
const { featureFlags } = useAppContext();
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
const optionList = getOptionList(t, isAnomalyDetectionEnabled);

View File

@@ -1,5 +1,8 @@
import { fireEvent, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import {
LicensePlatform,
LicenseResModel,
} from 'types/api/licensesV3/getActive';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
@@ -75,6 +78,10 @@ const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const CLOUD_LICENSE = {
platform: LicensePlatform.CLOUD,
} as LicenseResModel;
const mockSetUrlQuery = jest.fn();
const mockToString = jest.fn();
const mockGetUrlQuery = jest.fn();
@@ -152,7 +159,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +170,9 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {
@@ -272,15 +280,7 @@ describe('CreateAlertRule', () => {
it('should navigate with anomaly detection params for anomaly alert', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
activeLicense: CLOUD_LICENSE,
});
render(<CreateAlertRule />);

View File

@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
useEffect(() => {
if (isTypeSelectionMode) {
logEvent('Alert: New alert data source selection page visited', {});
void logEvent('Alert: New alert data source selection page visited', {});
}
}, [isTypeSelectionMode]);
@@ -100,10 +100,7 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -0,0 +1,7 @@
:global(.ant-tooltip).tabTooltip {
max-width: 360px;
}
.switchNote {
margin-top: var(--spacing-4);
}

View File

@@ -1,20 +1,30 @@
import { ReactNode } from 'react';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { Activity, ChartLine } from '@signozhq/icons';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { useIsAnomalyDetectionAvailable } from '../useIsAnomalyDetectionAvailable';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
import {
ANOMALY_TAB_SWITCH_NOTE,
ANOMALY_TAB_TOOLTIP,
THRESHOLD_TAB_SWITCH_NOTE,
THRESHOLD_TAB_TOOLTIP,
} from './constants';
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
import styles from './AlertCondition.module.scss';
import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
useAnomalyFunctionSync();
const {
data: channels,
@@ -23,9 +33,12 @@ function AlertCondition(): JSX.Element {
refetch: refreshChannels,
} = useChannelOptions();
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
// Anomaly rules stay editable even without a cloud or enterprise license.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
const tabs = [
{
@@ -33,16 +46,15 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
@@ -52,11 +64,23 @@ function AlertCondition(): JSX.Element {
setAlertType(value);
};
const getTabTooltip = (tab: { value: AlertTypes }): string => {
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
return ANOMALY_TAB_TOOLTIP;
const getTabTooltip = (tab: { value: AlertTypes }): ReactNode => {
const isAnomalyTab = tab.value === AlertTypes.ANOMALY_BASED_ALERT;
const description = isAnomalyTab
? ANOMALY_TAB_TOOLTIP
: THRESHOLD_TAB_TOOLTIP;
if (tab.value === alertType) {
return description;
}
return THRESHOLD_TAB_TOOLTIP;
const switchNote = isAnomalyTab
? ANOMALY_TAB_SWITCH_NOTE
: THRESHOLD_TAB_SWITCH_NOTE;
return (
<>
<div>{description}</div>
<div className={styles.switchNote}>{switchNote}</div>
</>
);
};
return (
@@ -65,7 +89,11 @@ function AlertCondition(): JSX.Element {
<div className="alert-condition">
<div className="alert-condition-tabs">
{tabs.map((tab) => (
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
<Tooltip
key={tab.value}
title={getTabTooltip(tab)}
overlayClassName={styles.tabTooltip}
>
<Button
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,

View File

@@ -1,18 +1,14 @@
import { useEffect } from 'react';
import { Button, Select, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Select, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import getRandomColor from 'lib/getRandomColor';
import { Plus } from '@signozhq/icons';
import { v4 } from 'uuid';
import { useCreateAlertState } from '../context';
import {
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD,
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
@@ -23,7 +19,8 @@ import {
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { normalizeMatchType, normalizeOperator } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import { AnomalyAndThresholdProps } from './types';
import { useThresholdList } from './useThresholdList';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
@@ -71,42 +68,7 @@ function AlertThreshold({
selectedCategory || '',
);
const addThreshold = (): void => {
let newThreshold;
if (thresholdState.thresholds.length === 1) {
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
} else if (thresholdState.thresholds.length === 2) {
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
} else {
newThreshold = {
...INITIAL_RANDOM_THRESHOLD,
id: v4(),
color: getRandomColor(),
};
}
setThresholdState({
type: 'SET_THRESHOLDS',
payload: [...thresholdState.thresholds, newThreshold],
});
};
const removeThreshold = (id: string): void => {
if (thresholdState.thresholds.length > 1) {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.filter((t) => t.id !== id),
});
}
};
const updateThreshold: UpdateThreshold = (id, field, value) => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
),
});
};
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
const onTooltipOpenChange = (open: boolean): void => {
// Stop propagation of click events on tooltip text to dropdown
@@ -294,11 +256,12 @@ function AlertThreshold({
/>
))}
<Button
type="dashed"
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
data-testid="add-threshold-button"
variant="dashed"
color="secondary"
size="md"
prefix={<Plus size={14} />}
onClick={(): void => addThreshold()}
testId="add-threshold-button"
>
Add Threshold
</Button>

View File

@@ -0,0 +1,13 @@
.querySelect {
width: 80px;
}
.algorithmSelect,
.seasonalitySelect {
width: 140px;
}
.operatorSelect,
.matchTypeSelect {
width: 180px;
}

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
import { Plus } from '@signozhq/icons';
import { useCreateAlertState } from '../context';
import {
@@ -10,19 +10,20 @@ import {
ANOMALY_SEASONALITY_OPTIONS,
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
INITIAL_ANOMALY_THRESHOLD,
} from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { normalizeMatchType, normalizeOperator } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
NotificationChannelsNotFoundContent,
RoutingPolicyBanner,
} from './utils';
import { useThresholdList } from './useThresholdList';
import { getQueryNames, RoutingPolicyBanner } from './utils';
import styles from './AnomalyThreshold.module.scss';
function AnomalyThreshold({
channels,
@@ -30,7 +31,6 @@ function AnomalyThreshold({
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { user } = useAppContext();
const {
thresholdState,
setThresholdState,
@@ -42,23 +42,13 @@ function AnomalyThreshold({
const queryNames = getQueryNames(currentQuery);
const deviationOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 7; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
const updateThreshold = (
id: string,
field: string,
value: string | string[],
): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
t.id === id ? { ...t, [field]: value } : t,
const handleAddThreshold = (): void => {
addThreshold({
thresholdValue: Math.max(
1,
INITIAL_ANOMALY_THRESHOLD.thresholdValue - thresholdState.thresholds.length,
),
});
};
@@ -69,11 +59,12 @@ function AnomalyThreshold({
{/* Sentence 1 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="notification-text" className="sentence-text">
Send notification when the observed value for
Send a notification when
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
data-testid="query-select"
className={styles.querySelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
@@ -82,50 +73,16 @@ function AnomalyThreshold({
}}
options={queryNames}
/>
<Typography.Text
data-testid="evaluation-window-text"
className="sentence-text"
>
during the last
</Typography.Text>
<Select
value={thresholdState.evaluationWindow}
data-testid="evaluation-window-select"
onChange={(value): void => {
setThresholdState({
type: 'SET_EVALUATION_WINDOW',
payload: value,
});
}}
options={ANOMALY_TIME_DURATION_OPTIONS}
/>
</div>
<div className="alert-condition-sentence">
{/* Sentence 2 */}
<Typography.Text data-testid="threshold-text" className="sentence-text">
is
</Typography.Text>
<Select
value={thresholdState.thresholds[0].thresholdValue}
data-testid="threshold-value-select"
onChange={(value): void => {
updateThreshold(
thresholdState.thresholds[0].id,
'thresholdValue',
value.toString(),
);
}}
options={deviationOptions}
/>
<Typography.Text data-testid="deviations-text" className="sentence-text">
deviations
</Typography.Text>
<Select
value={
(normalizeOperator(thresholdState.operator) ??
thresholdState.operator) as AlertThresholdOperator
}
data-testid="operator-select"
className={styles.operatorSelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
@@ -138,7 +95,7 @@ function AnomalyThreshold({
data-testid="predicted-data-text"
className="sentence-text"
>
the predicted data
the prediction
</Typography.Text>
<Select
value={
@@ -146,6 +103,7 @@ function AnomalyThreshold({
thresholdState.matchType) as AlertThresholdMatchType
}
data-testid="match-type-select"
className={styles.matchTypeSelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
@@ -154,15 +112,22 @@ function AnomalyThreshold({
}}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text
data-testid="evaluation-window-text"
className="sentence-text"
>
during the <EvaluationSettings rollingOnly />
</Typography.Text>
</div>
{/* Sentence 3 */}
<div className="alert-condition-sentence">
{/* Sentence 2 */}
<Typography.Text data-testid="using-the-text" className="sentence-text">
using the
</Typography.Text>
<Select
value={thresholdState.algorithm}
data-testid="algorithm-select"
className={styles.algorithmSelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_ALGORITHM',
@@ -180,6 +145,7 @@ function AnomalyThreshold({
<Select
value={thresholdState.seasonality}
data-testid="seasonality-select"
className={styles.seasonalitySelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_SEASONALITY',
@@ -188,52 +154,39 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"
className="sentence-text"
>
seasonality to
</Typography.Text>
<Select
value={thresholdState.thresholds[0].channels}
onChange={(value): void =>
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={
<NotificationChannelsNotFoundContent
user={user}
refreshChannels={refreshChannels}
/>
}
/>
</>
) : (
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
)}
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
</div>
</div>
{/* Sentence 3 */}
<div className="thresholds-section">
{thresholdState.thresholds.map((threshold, index) => (
<ThresholdItem
key={threshold.id}
threshold={threshold}
updateThreshold={updateThreshold}
removeThreshold={removeThreshold}
showRemoveButton={index !== 0 && thresholdState.thresholds.length > 1}
channels={channels}
isLoadingChannels={isLoadingChannels}
units={[]}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
isAnomaly
/>
))}
<Button
variant="dashed"
color="secondary"
size="md"
prefix={<Plus size={14} />}
onClick={handleAddThreshold}
testId="add-threshold-button"
>
Add Threshold
</Button>
</div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}

View File

@@ -21,6 +21,7 @@ function ThresholdItem({
isErrorChannels,
refreshChannels,
isLoadingChannels,
isAnomaly = false,
}: ThresholdItemProps): JSX.Element {
const { user } = useAppContext();
const { thresholdState, notificationSettings } = useCreateAlertState();
@@ -104,10 +105,16 @@ function ThresholdItem({
style={{ width: 200 }}
data-testid="threshold-name-input"
/>
<Typography.Text className="sentence-text">on value</Typography.Text>
<Typography.Text className="sentence-text highlighted-text">
{getOperatorSymbol()}
</Typography.Text>
{isAnomaly ? (
<Typography.Text className="sentence-text">at</Typography.Text>
) : (
<>
<Typography.Text className="sentence-text">on value</Typography.Text>
<Typography.Text className="sentence-text highlighted-text">
{getOperatorSymbol()}
</Typography.Text>
</>
)}
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
@@ -118,7 +125,11 @@ function ThresholdItem({
type="number"
data-testid="threshold-value-input"
/>
{yAxisUnitSelect}
{isAnomaly ? (
<Typography.Text className="sentence-text">deviations</Typography.Text>
) : (
yAxisUnitSelect
)}
{!notificationSettings.routingPolicies && (
<>
<Typography.Text className="sentence-text">send to</Typography.Text>

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { IAppContext } from 'providers/App/types';
import { render, screen, userEvent } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -65,54 +65,55 @@ jest.mock('../AnomalyThreshold', () => ({
},
}));
const mockHandleSetQueryData: jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
> = jest.fn();
const mockRedirectWithQueryBuilderData: jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
> = jest.fn();
const mockCurrentQuery = {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
};
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
builder: { queryData: unknown[]; queryFormulas: unknown[] };
dataSource: string;
queryName: string;
};
redirectWithQueryBuilderData: () => void;
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
},
redirectWithQueryBuilderData: jest.fn(),
useQueryBuilder: (): unknown => ({
currentQuery: mockCurrentQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
handleSetQueryData: mockHandleSetQueryData,
}),
}));
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
const queryClient = createTestQueryClient();
const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined;
return render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertCondition />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
appContextOverrides?: Partial<IAppContext>,
): ReturnType<typeof render> =>
render(
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertCondition />
</CreateAlertProvider>,
undefined,
{
initialRoute: alertType ? `/?alertType=${alertType}` : '/',
appContextOverrides,
},
);
const clickText = async (text: string): Promise<void> => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByText(text));
};
describe('AlertCondition', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -125,10 +126,9 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -136,8 +136,7 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -152,8 +151,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
@@ -167,36 +165,47 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
it('warns in the tab tooltip that switching changes what gets saved', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderAlertCondition();
await user.hover(screen.getByText(ANOMALY_TAB_TEXT));
await expect(
screen.findByText(/saves this as an anomaly-based alert/),
).resolves.toBeInTheDocument();
await user.click(screen.getByText(ANOMALY_TAB_TEXT));
await user.hover(screen.getByText(THRESHOLD_TAB_TEXT));
await expect(
screen.findByText(/saves this as a metrics-based alert/),
).resolves.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', async () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
await clickText(ANOMALY_TAB_TEXT);
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
it('switches between threshold and anomaly tabs', async () => {
renderAlertCondition();
// Initially shows threshold component
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// Click anomaly tab
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
await clickText(ANOMALY_TAB_TEXT);
// Should show anomaly component
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
// Click threshold tab
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
fireEvent.click(thresholdTab);
await clickText(THRESHOLD_TAB_TEXT);
// Should show threshold component again
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -205,8 +214,40 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
it('adds the anomaly function with the default z-score when switching to anomaly', async () => {
renderAlertCondition();
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
await clickText(ANOMALY_TAB_TEXT);
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({
functions: [
{
name: 'anomaly',
args: [],
namedArgs: { z_score_threshold: 3, seasonality: 'hourly' },
},
],
}),
);
});
it('keeps the current query when switching between threshold and anomaly', async () => {
renderAlertCondition();
await clickText(ANOMALY_TAB_TEXT);
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
mockCurrentQuery,
{ alertType: AlertTypes.ANOMALY_BASED_ALERT, ruleType: 'anomaly_rule' },
undefined,
true,
);
});
it('applies active tab styling correctly', async () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -217,7 +258,7 @@ describe('AlertCondition', () => {
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
// Click anomaly tab
fireEvent.click(anomalyTab);
await clickText(ANOMALY_TAB_TEXT);
// Anomaly tab should be active now
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
@@ -227,11 +268,17 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('hides the anomaly tab for METRICS_BASED_ALERT on the community edition', () => {
renderAlertCondition('METRIC_BASED_ALERT', { activeLicense: null });
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -239,9 +286,8 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,13 +1,11 @@
import { render, screen } from '@testing-library/react';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import * as context from '../../context';
import { AlertThresholdMatchType } from '../../context/types';
import AnomalyThreshold from '../AnomalyThreshold';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -22,12 +20,11 @@ jest.mock('uplot', () => {
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
const mockContextState = createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
});
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -52,10 +49,12 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}),
}));
const channels: ChannelOption[] = [{ id: '1', name: 'slack-alerts' }];
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(
<AnomalyThreshold
channels={[]}
channels={channels}
isLoadingChannels={false}
isErrorChannels={false}
refreshChannels={jest.fn()}
@@ -65,31 +64,117 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
describe('AnomalyThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
useCreateAlertStateSpy.mockReturnValue(mockContextState);
});
it('renders the first condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
});
const pickOption = async (testId: string, title: string): Promise<void> => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(within(screen.getByTestId(testId)).getByRole('combobox'));
await user.click(await screen.findByTitle(title));
};
it('renders the second condition sentence', () => {
it('renders the condition sentence in the same order as threshold alerts', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
expect(screen.getByTestId('notification-text')).toHaveTextContent(
'Send a notification when',
);
expect(screen.getByTestId('query-select')).toBeInTheDocument();
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
expect(screen.getByTestId('predicted-data-text')).toHaveTextContent(
'the prediction',
);
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-settings-button')).toHaveTextContent(
'Rolling',
);
});
it('renders the third condition sentence', () => {
it('renders the algorithm and seasonality sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('using-the-text')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-select')).toBeInTheDocument();
expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-text')).toHaveTextContent(
'seasonality',
);
});
it('renames the severity from its threshold row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderAnomalyThreshold();
await user.type(screen.getByTestId('threshold-name-input'), 'x');
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [expect.objectContaining({ label: 'criticalx' })],
});
});
it('takes a typed deviation', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
useCreateAlertStateSpy.mockReturnValue({
...mockContextState,
thresholdState: {
...mockContextState.thresholdState,
thresholds: [
{
...mockContextState.thresholdState.thresholds[0],
thresholdValue: '' as unknown as number,
},
],
},
});
renderAnomalyThreshold();
await user.type(screen.getByTestId('threshold-value-input'), '4');
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [expect.objectContaining({ thresholdValue: '4' })],
});
});
it.each([
['ON AVERAGE', AlertThresholdMatchType.ON_AVERAGE],
['LAST', AlertThresholdMatchType.LAST],
])('offers the %s match type', async (title, matchType) => {
renderAnomalyThreshold();
await pickOption('match-type-select', title);
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_MATCH_TYPE',
payload: matchType,
});
});
it('picks channels by name when routing policies are off', async () => {
renderAnomalyThreshold();
await pickOption('threshold-notification-channel-select', 'slack-alerts');
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [expect.objectContaining({ channels: ['slack-alerts'] })],
});
});
it('adds the next severity one deviation lower', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderAnomalyThreshold();
await user.click(screen.getByTestId('add-threshold-button'));
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [
mockContextState.thresholdState.thresholds[0],
expect.objectContaining({ label: 'warning', thresholdValue: 2 }),
],
});
});
it('hides the channel picker when routing policies are on', () => {
useCreateAlertStateSpy.mockReturnValue({
...mockContextState,
notificationSettings: {
...mockContextState.notificationSettings,
routingPolicies: true,
},
});
renderAnomalyThreshold();
expect(
screen.queryByTestId('threshold-notification-channel-select'),
).not.toBeInTheDocument();
});
});

View File

@@ -149,6 +149,14 @@ describe('ThresholdItem', () => {
expect(valueInput).toHaveValue(100);
});
it('asks for deviations instead of a unit for anomaly alerts', () => {
renderThresholdItem({ isAnomaly: true });
expect(screen.getByText('deviations')).toBeInTheDocument();
expect(screen.queryByText('on value')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-unit-select')).not.toBeInTheDocument();
});
it('renders unit selector with correct value', () => {
renderThresholdItem();

View File

@@ -0,0 +1,58 @@
import { QueryFunction } from 'types/api/v5/queryRange';
import { INITIAL_CRITICAL_THRESHOLD } from '../../context/constants';
import { Threshold } from '../../context/types';
import { getSmallestDeviation, syncAnomalyFunction } from '../utils';
const timeShift: QueryFunction = { name: 'timeShift', args: [{ value: 3600 }] };
const anomaly = (
zScoreThreshold: number,
seasonality: string,
): QueryFunction => ({
name: 'anomaly',
args: [],
namedArgs: { z_score_threshold: zScoreThreshold, seasonality },
});
describe('syncAnomalyFunction', () => {
it('adds the anomaly function after the existing functions', () => {
expect(
syncAnomalyFunction([timeShift], {
z_score_threshold: 3,
seasonality: 'daily',
}),
).toStrictEqual([timeShift, anomaly(3, 'daily')]);
});
it('replaces stale args and gives the same result when run again', () => {
const namedArgs = { z_score_threshold: 5, seasonality: 'weekly' };
const synced = syncAnomalyFunction(
[anomaly(3, 'hourly'), timeShift],
namedArgs,
);
expect(synced).toStrictEqual([timeShift, anomaly(5, 'weekly')]);
expect(syncAnomalyFunction(synced, namedArgs)).toStrictEqual(synced);
});
it('removes the anomaly function when no args are given', () => {
expect(
syncAnomalyFunction([anomaly(3, 'daily'), timeShift], undefined),
).toStrictEqual([timeShift]);
});
});
describe('getSmallestDeviation', () => {
it.each([
[[3, 2], 2],
[['2.5', 4], 2.5],
[[3, '', 0], 3],
[[''], 0],
])('picks the smallest valid deviation from %p', (values, expected) => {
const thresholds = values.map(
(thresholdValue) =>
({ ...INITIAL_CRITICAL_THRESHOLD, thresholdValue }) as Threshold,
);
expect(getSmallestDeviation(thresholds)).toBe(expected);
});
});

View File

@@ -4,5 +4,11 @@ export const THRESHOLD_TAB_TOOLTIP =
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';
export const THRESHOLD_TAB_SWITCH_NOTE =
'Switching keeps your query but saves this as a metrics-based alert, not an anomaly alert.';
export const ANOMALY_TAB_SWITCH_NOTE =
'Switching keeps your query but saves this as an anomaly-based alert. Anomaly detection works on Query Builder metric queries only.';
export const ROUTING_POLICIES_ROUTE =
'/alerts?tab=Configuration&subTab=routing-policies';

View File

@@ -90,8 +90,6 @@
}
.ant-select {
width: 240px;
.ant-select-selector {
background-color: var(--l2-background);
border: 1px solid var(--l1-border);
@@ -255,28 +253,6 @@
}
}
}
.add-threshold-btn {
margin-top: 8px;
border: 1px dashed var(--l1-border);
color: var(--l2-foreground);
background-color: transparent;
border-radius: 4px;
height: 32px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
.anticon {
margin-right: 8px;
}
}
}
.routing-policies-info-banner {
@@ -314,14 +290,6 @@
}
}
.anomaly-threshold-container {
.ant-select {
.ant-select-selector {
min-width: 150px;
}
}
}
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;

View File

@@ -26,6 +26,13 @@ export interface ThresholdItemProps {
units: DefaultOptionType[];
isErrorChannels: boolean;
refreshChannels: () => void;
isAnomaly?: boolean;
}
export interface ThresholdList {
addThreshold: (overrides?: Partial<Threshold>) => void;
removeThreshold: (thresholdId: string) => void;
updateThreshold: UpdateThreshold;
}
export interface AnomalyAndThresholdProps {

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual } from 'lodash-es';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import { getSmallestDeviation, syncAnomalyFunction } from './utils';
// These args only drive the preview; the rule reads the threshold target and condition.seasonality.
export function useAnomalyFunctionSync(): void {
const { alertType, thresholdState } = useCreateAlertState();
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const isAnomalyAlert = alertType === AlertTypes.ANOMALY_BASED_ALERT;
const zScoreThreshold = getSmallestDeviation(thresholdState.thresholds);
const { seasonality } = thresholdState;
useEffect(() => {
const namedArgs = isAnomalyAlert
? { z_score_threshold: zScoreThreshold, seasonality }
: undefined;
currentQuery.builder.queryData.forEach((queryData, index) => {
const currentFunctions = queryData.functions || [];
const functions = syncAnomalyFunction(currentFunctions, namedArgs);
if (!isEqual(functions, currentFunctions)) {
handleSetQueryData(index, { ...queryData, functions });
}
});
}, [
currentQuery.builder.queryData,
isAnomalyAlert,
zScoreThreshold,
seasonality,
handleSetQueryData,
]);
}

View File

@@ -0,0 +1,53 @@
import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid';
import { useCreateAlertState } from '../context';
import {
INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD,
} from '../context/constants';
import { Threshold } from '../context/types';
import { ThresholdList, UpdateThreshold } from './types';
export function useThresholdList(): ThresholdList {
const { thresholdState, setThresholdState } = useCreateAlertState();
const { thresholds } = thresholdState;
const addThreshold = (overrides?: Partial<Threshold>): void => {
let newThreshold;
if (thresholds.length === 1) {
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
} else if (thresholds.length === 2) {
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
} else {
newThreshold = {
...INITIAL_RANDOM_THRESHOLD,
id: v4(),
color: getRandomColor(),
};
}
setThresholdState({
type: 'SET_THRESHOLDS',
payload: [...thresholds, { ...newThreshold, ...overrides }],
});
};
const removeThreshold = (id: string): void => {
if (thresholds.length > 1) {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholds.filter((t) => t.id !== id),
});
}
};
const updateThreshold: UpdateThreshold = (id, field, value) => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholds.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
});
};
return { addThreshold, removeThreshold, updateThreshold };
}

View File

@@ -15,11 +15,13 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight } from '@signozhq/icons';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { Threshold } from '../context/types';
import { RoutingPolicyBannerProps } from './types';
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
@@ -43,6 +45,24 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
return queryConfig[currentQuery.queryType]?.() || [];
}
export function getSmallestDeviation(thresholds: Threshold[]): number {
const deviations = thresholds
.map(({ thresholdValue }) => Number(thresholdValue))
.filter((deviation) => deviation > 0);
return deviations.length > 0 ? Math.min(...deviations) : 0;
}
export function syncAnomalyFunction(
functions: QueryFunction[],
namedArgs: QueryFunction['namedArgs'],
): QueryFunction[] {
const otherFunctions = functions.filter(({ name }) => name !== 'anomaly');
if (!namedArgs) {
return otherFunctions;
}
return [...otherFunctions, { name: 'anomaly', args: [], namedArgs }];
}
export function getCategoryByOptionId(id: string): string | undefined {
const categories = getYAxisCategories(YAxisSource.ALERTS);
return categories.find((category) =>
@@ -223,17 +243,22 @@ function TooltipExample({
thresholdValue: number;
matchType: AlertThresholdMatchType;
}): JSX.Element {
const pointsLabel =
matchType === AlertThresholdMatchType.IN_TOTAL
? 'error counts'
: 'data points';
return (
<div className="tooltip-example">
<strong>Example:</strong>
<br />
Say, For a 5-minute window (configured in Evaluation settings), 1 min
aggregation interval (set up in query) → 5{' '}
{matchType === AlertThresholdMatchType.IN_TOTAL
? 'error counts'
: 'data points'}
: [{dataPoints.join(', ')}]<br />
With threshold {operatorSymbol} {thresholdValue}: {children}
<span className="translate-safe">
{`Say, For a 5-minute window (configured in Evaluation settings), 1 min aggregation interval (set up in query) → 5 ${pointsLabel}: [${dataPoints.join(', ')}]`}
</span>
<br />
<span className="translate-safe">
{`With threshold ${operatorSymbol} ${thresholdValue}: `}
</span>
{children}
</div>
);
}

View File

@@ -4,11 +4,14 @@ import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { useCreateAlertState } from '../context';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { IEvaluationSettingsProps } from './types';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
import './styles.scss';
function EvaluationSettings(): JSX.Element {
function EvaluationSettings({
rollingOnly = false,
}: IEvaluationSettingsProps): JSX.Element {
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
const [isEvaluationWindowPopoverOpen, setIsEvaluationWindowPopoverOpen] =
@@ -24,6 +27,7 @@ function EvaluationSettings(): JSX.Element {
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
rollingOnly={rollingOnly}
/>
}
trigger="click"

View File

@@ -20,7 +20,12 @@ import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNa
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
rollingOnly = false,
}: IEvaluationWindowPopoverProps): JSX.Element {
const windowTypeOptions = rollingOnly
? EVALUATION_WINDOW_TYPE.filter((option) => option.value === 'rolling')
: EVALUATION_WINDOW_TYPE;
const { containerRef, firstItemRef } =
useKeyboardNavigationForEvaluationWindowPopover({
onSelect: (value: string, sectionId: string): void => {
@@ -136,7 +141,7 @@ function EvaluationWindowPopover({
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
windowTypeOptions,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({

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