Compare commits

..

17 Commits

Author SHA1 Message Date
Aditya Singh
5b5b9794f9 chore(saved-views): retire the old views pages, v1 client and the flag (#13258)
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
- The new saved views panel and the bottom strip are on for everyone.
This PR removes the old saved views code they replaced:
- the Views tabs and the saved views page on Logs, Traces, Metrics and
Meter
  - the `ExplorerCard` and `ExplorerOptions` bars
  - the v1 saved views client
  - the `SAVED_VIEW_ENABLED` flag
- Old views URLs still redirect to their explorer.
- The explorer URL now carries only `viewKey`. The app no longer writes
`viewName`.
- `ExplorerCard` no longer runs. Its header was hidden, but it still
fetched the v1 views list on every Logs, Traces and LLM explorer load.
- Stories now mock `/api/v2/saved_views`, and the old Views tab stories
are gone.

#### Issues closed by this PR

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

#### Additional Information
- The dashboards list status bar and the side nav Noz entry stay. Both
are still live UI, and the status bar holds the only rail toggle.
- Noz no longer gets the view name in its page context. It still gets
the view id.
2026-10-28 14:30:39 +00:00
Jugal Kishore
1957e5a112 feat(onboarding): add Hono, Caddy, PM2, and GCP one-click datasources (#13291)
#### Description

- Add onboarding cards for Hono, Caddy, PM2, and GCP One-Click
Integration.
- GCP Integration card: add `logs` tag and log keywords, and route to
Integrations after onboarding.
- Add Hono, Caddy, and PM2 logos.

#### Issues closed by this PR

Closes SigNoz/signoz.io#4390
Closes SigNoz/signoz.io#4387
Closes SigNoz/signoz.io#4370
Closes SigNoz/signoz.io#4367
Closes SigNoz/signoz.io#4356
2026-10-28 13:42:02 +00:00
Nikhil Soni
18eb8ca849 feat(tracedetail): read waterfall/flamegraph attributes from JSON column (#13274)
#### Description

- `StorableSpan.Attributes()` prefers the flattened JSON column per span
and falls back to the legacy maps, so waterfall, flamegraph and thread
share one attribute bag (replaces the thread-only helper and
`AttributeValue`).
- Waterfall and flamegraph always read `attributes`; maps are blanked in
SQL for rows whose JSON is non-empty (`if(notEmpty(attributes), map(),
…)`), so dual-written spans ship attributes once on 10k/100k-span loads
while pre-rollout spans keep their maps.
- Attribute reads are no longer gated on `use_trace_attributes_json`, so
the waterfall store and unexported module helpers drop their unused
`orgID`.

#### Additional Information

- Requires the `attributes` column on `signoz_index_v3` (otel-collector
schema migration); without it waterfall/flamegraph error instead of
falling back.
- Flamegraph `GROUP BY`: the blanking `if` wraps `any(<map>)`, not the
reverse — `any(attributes) AS attributes` makes `attributes` resolve to
the aggregated alias, and nesting it is `ILLEGAL_AGGREGATION`.
- Thread reads `attributes` and the maps as-is (pages are capped at ~100
spans): its gen_ai filter reads `attributes_string` in WHERE, which a
blanking alias would shadow and drop dual-written spans.
- Supersedes #12798.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:05:43 +00:00
Ashwin Bhatkal
3b34a0d951 fix(notification-channels): match the relaxed channel checks and pick webhook auth with a radio (#13276)
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

- Drops the Google Chat, Jira and incident.io URL checks the API removed
in #13008, so a proxy or relay in front of those vendors can be saved.
- Webhook auth is now a None / Basic auth / Bearer token radio. Only the
chosen credentials are shown and sent, so a username plus a bearer token
can no longer reach the API and fail.
- A webhook username without a password is now allowed, matching the
API.
- The form now catches what the API still rejects: a Slack field without
a value, a Slack button without a URL or name, and an incident.io token
with a `Bearer ` prefix or surrounding spaces.
- Form errors are now nested by field path, so a Slack row error shows
under its row.

#### Issues closed by this PR

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


#### Screenshots / Screen Recordings

Visual diff of every `Pages/Alerts/Channels` story, dark and light,
against the merge-base:
https://claude.ai/artifact/GBULJmZFpzhe5bRviSnBoY

- 4 pairs changed: the webhook form on the new and edit pages.
- 16 new pairs, from 8 new stories.
- 125 of 148 pairs are pixel-identical.
- 3 pairs moved for reasons outside this change: a focus ring present in
one run only.
2026-10-28 11:23:55 +00:00
Abhi kumar
0036cda4f1 refactor(query-builder): drop stale bar-panel data-source override (#13218)
#### Description

- `QueryBuilderV2` pinned the provider's `initialDataSource` to metrics
for bar panels, a leftover from when bar charts were metrics-only. It
has no visible effect today, but implies bar panels are metrics-only.
- Removed it and the AI override that only existed to undo it; the AI
tab already passes a static traces config.
- Added tests for the provider config sync.

Closes https://github.com/SigNoz/pulse-pod/issues/603
2026-10-28 09:03:16 +00:00
Nityananda Gohain
665e70b17a chore: add ai qb examples (#13282)
#### Description
Adding ai qb examples

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/289
2026-10-28 06:17:51 +00:00
Abhi kumar
450ceed5fd test(dashboards): panel stories with play assertions for every kind (#13244)
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

Base #13271, which puts the uPlot instance on the chart container; this
PR no longer touches UPlotChart.

- `Pages/Dashboards/Detail/Panels`: the real dashboard page around a
one-panel dashboard of any of the 10 kinds, with controls for kind,
legend, thresholds, unit, stacking, y-scale, line style, points, gaps,
column units, bucket width, heatmap palette / scale / count range, Text
body / alignment, result (data / empty / warning), series and List rows.
Loading and failed come from the global Data control.
- 26 stories, each with a `play` that asserts what the kind draws: uPlot
series, scales, bands and dash read off the live instance; threshold
lines read from canvas pixels; per-series 100% shares; histogram bins on
the bucket width; heatmap colour-bar endpoints; pie arcs and the exact
total; number value and threshold colour; table cells painted per
column; list pages and the offset each page requests; Text markdown.
- Per review, this is where per-kind chart and config detail lives; the
e2e kind specs (#13233, #13273) keep only flows and smoke checks.
- `ConfigPane.test.tsx` renders each kind's config pane and checks the
sections it shows.
- Shares the document store, public-link meta and warning with the
DashboardPage stories; the table and heatmap `query_range` builders take
a `warning` like the others.

#### Additional Information

- Runs in the existing `storybook` CI job; no screenshots or binaries
committed.
2026-10-27 19:47:36 +00:00
Aditya Singh
63d0470894 refactor(quick-filters): one quick filter source drives the saved list and the fields api signal (#12832)
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
- 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
383 changed files with 15088 additions and 15194 deletions

View File

@@ -46,9 +46,9 @@ One endpoint feeding several widgets stays one handler that reads the request. `
```ts
rest.get(
'http://localhost/api/v1/explorer/views',
'http://localhost/api/v2/saved_views',
response.json((req) =>
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
savedViews(values.savedViews, req.url.searchParams.get('source') ?? 'logs'),
),
),
```

View File

@@ -31970,6 +31970,132 @@ paths:
content:
application/json:
examples:
ai_traces_raw:
summary: 'Raw: LLM, tool, and agent spans of one service''s AI traces
with many tool calls'
value:
compositeQuery:
queries:
- spec:
filter:
expression: service.name = 'ai-agent' AND trace.tool_call_count
> 10
limit: 100
name: A
order:
- direction: desc
key:
fieldContext: span
name: timestamp
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: raw
schemaVersion: v1
start: 1640995200000
ai_traces_raw_select_fields:
summary: 'Raw: the same spans with the span name and gen_ai model,
tool, and token fields selected'
value:
compositeQuery:
queries:
- spec:
filter:
expression: service.name = 'ai-agent' AND trace.tool_call_count
> 10
limit: 100
name: A
order:
- direction: desc
key:
fieldContext: span
name: timestamp
selectFields:
- fieldContext: span
name: name
- fieldContext: attribute
name: gen_ai.request.model
- fieldContext: attribute
name: gen_ai.tool.name
- fieldContext: attribute
name: gen_ai.usage.input_tokens
- fieldContext: attribute
name: gen_ai.usage.output_tokens
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: raw
schemaVersion: v1
start: 1640995200000
ai_traces_scalar:
summary: 'Scalar: average per-trace AI cost by model'
value:
compositeQuery:
queries:
- spec:
aggregations:
- expression: avg(trace.estimated_total_cost)
groupBy:
- fieldContext: attribute
name: gen_ai.request.model
limit: 10
name: A
order:
- direction: desc
key:
name: avg(trace.estimated_total_cost)
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: scalar
schemaVersion: v1
start: 1640995200000
ai_traces_time_series:
summary: 'Time series: per-trace AI cost by model'
value:
compositeQuery:
queries:
- spec:
aggregations:
- expression: sum(trace.estimated_total_cost)
groupBy:
- fieldContext: attribute
name: gen_ai.request.model
limit: 10
name: A
order:
- direction: desc
key:
name: sum(trace.estimated_total_cost)
signal: traces
stepInterval: 60s
type: builder_ai_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
ai_traces_trace:
summary: 'Trace list: AI traces with many tool calls, most expensive
first'
value:
compositeQuery:
queries:
- spec:
filter:
expression: trace.tool_call_count > 10 AND trace.distinct_tool_count
< 3
limit: 10
name: A
order:
- direction: desc
key:
name: trace.estimated_total_cost
signal: traces
type: builder_ai_query
end: 1640998800000
requestType: trace
schemaVersion: v1
start: 1640995200000
clickhouse_sql_logs_raw:
summary: 'ClickHouse SQL: raw logs with resource filter'
value:

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

@@ -127,6 +127,7 @@ const preview: Preview = {
'Detail',
[
'Overview',
'Panels',
'Settings',
'Variables',
'New Variable',
@@ -140,7 +141,7 @@ const preview: Preview = {
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',

View File

@@ -17,11 +17,9 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
@@ -82,7 +80,6 @@
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"field_incidentio_metadata": "Additional metadata",
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
@@ -92,8 +89,8 @@
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -112,13 +109,10 @@
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",

View File

@@ -1,3 +0,0 @@
{
"name_of_the_view": "Name of the view"
}

View File

@@ -47,7 +47,6 @@
"INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring",
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",

View File

@@ -25,11 +25,9 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
"field_jira_site": "Site URL",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
"jira_service_account_tip_link": "Learn how",
@@ -90,7 +88,8 @@
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"incidentio_token_bearer_prefix": "Paste the token only, without the Bearer prefix",
"incidentio_token_whitespace": "Remove the spaces around the token",
"field_incidentio_metadata": "Additional metadata",
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
@@ -116,6 +115,7 @@
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"slack_field_value_required": "Enter a value for this field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"label_slack_action_text": "Text",
@@ -133,6 +133,7 @@
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"slack_action_url_or_name_required": "Enter a URL or a name for this button",
"remove_slack_action": "Remove action",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
@@ -143,9 +144,15 @@
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_webhook_bearer_token": "Bearer token (optional)",
"field_webhook_username": "Username",
"field_webhook_password": "Password",
"field_webhook_bearer_token": "Bearer token",
"field_webhook_auth": "Authentication",
"option_webhook_auth_none": "None",
"option_webhook_auth_basic": "Basic auth",
"option_webhook_auth_bearer": "Bearer token",
"webhook_basic_auth_required": "Enter a username or password",
"webhook_bearer_token_required": "Enter a bearer token",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -168,9 +175,7 @@
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header.",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
@@ -178,7 +183,6 @@
"channel_creation_done": "Successfully created the channel",
"channel_edit_done": "Channels Edited Successfully",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",

View File

@@ -1,4 +0,0 @@
{
"name_of_the_view": "Name of the view",
"delete_confirm_message": "Are you sure you want to delete {{viewName}} view? Deleting a view is irreversible and cannot be undone."
}

View File

@@ -50,8 +50,6 @@
"WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended",
"WORKSPACE_ACCESS_RESTRICTED": "SigNoz | Workspace Access Restricted",
"SUPPORT": "SigNoz | Support",
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
"TRACES_FUNNELS": "SigNoz | Traces Funnels",
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
@@ -66,10 +64,8 @@
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
"API_MONITORING": "SigNoz | External APIs",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",

View File

@@ -303,10 +303,10 @@ describe('PrivateRoute', () => {
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
['/logs/saved-views', ROUTES.LOGS_EXPLORER],
['/traces/saved-views', ROUTES.TRACES_EXPLORER],
['/metrics-explorer/views', ROUTES.METRICS_EXPLORER_EXPLORER],
['/meter/explorer/views', ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });

View File

@@ -18,7 +18,6 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -66,7 +65,6 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
@@ -256,18 +254,8 @@ function App(): JSX.Element {
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
pathname.startsWith('/ai-assistant/')
) {
window.Pylon?.('hideChatBubble');
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname, isSavedViewEnabled]);
window.Pylon?.('hideChatBubble');
}, [pathname]);
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.

View File

@@ -26,10 +26,6 @@ export const ServiceMapPage = Loadable(
() => import(/* webpackChunkName: "ServiceMapPage" */ 'modules/Servicemap'),
);
export const LogsSaveViews = Loadable(
() => import(/* webpackChunkName: "LogsSaveViews" */ 'pages/LogsModulePage'), // TODO: Add a wrapper so that the same component can be used in traces
);
export const TracesExplorer = Loadable(
() =>
import(
@@ -37,11 +33,6 @@ export const TracesExplorer = Loadable(
),
);
export const TracesSaveViews = Loadable(
() =>
import(/* webpackChunkName: "Traces Save Views" */ 'pages/TracesModulePage'),
);
export const TracesFunnels = Loadable(
() =>
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),

View File

@@ -26,7 +26,6 @@ import {
Login,
Logs,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
@@ -48,7 +47,6 @@ import {
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
TracesSaveViews,
UnAuthorized,
UsageExplorerPage,
WorkspaceAccessRestricted,
@@ -120,13 +118,6 @@ const routes: AppRoutes[] = [
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.LOGS_SAVE_VIEWS,
component: LogsSaveViews,
isPrivate: true,
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -218,13 +209,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_SAVE_VIEWS,
exact: true,
component: TracesSaveViews,
isPrivate: true,
key: 'TRACES_SAVE_VIEWS',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
@@ -414,13 +398,6 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VIEWS,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
@@ -443,13 +420,6 @@ const routes: AppRoutes[] = [
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER_VIEWS,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,

View File

@@ -2,15 +2,6 @@ import { AxiosError, AxiosResponse, isCancel } from 'axios';
import { ErrorResponse } from 'types/api';
import { ErrorStatusCode } from 'types/common';
function getClientErrorMessage(data: AxiosResponse['data']): string {
const { errors, error } = data;
if (Array.isArray(errors) && errors.length >= 1) {
return errors[0].msg;
}
// v2 endpoints send `error` as an `{ code, message }` envelope.
return typeof error?.message === 'string' ? error.message : error;
}
export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
const { response, request } = error;
if (response) {
@@ -30,10 +21,15 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
};
}
const { errors, error } = data;
const errorMessage =
Array.isArray(errors) && errors.length >= 1 ? errors[0].msg : error;
return {
statusCode,
payload: null,
error: getClientErrorMessage(data),
error: errorMessage,
message: (response.data as any)?.status,
body: JSON.stringify((response.data as any).data),
};

View File

@@ -21,19 +21,4 @@ describe('ErrorResponseHandler', () => {
consoleError.mockRestore();
});
it('flattens a v2 error envelope on a 4xx to its message', () => {
const error = new AxiosError('Request failed', '400', undefined, undefined, {
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as never,
data: {
status: 'error',
error: { code: 'invalid_input', message: 'Syntax error: bad query' },
},
});
expect(ErrorResponseHandler(error).error).toBe('Syntax error: bad query');
});
});

View File

@@ -1,5 +0,0 @@
import axios from 'api';
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
export const deleteView = (uuid: string): Promise<DeleteViewPayloadProps> =>
axios.delete(`/explorer/views/${uuid}`);

View File

@@ -1,9 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { AllViewsProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const getAllViews = (
sourcepage: DataSource | 'meter',
): Promise<AxiosResponse<AllViewsProps>> =>
axios.get(`/explorer/views?sourcePage=${sourcepage}`);

View File

@@ -1,24 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { ViewProps } from 'types/api/saveViews/types';
/**
* Fetches a single saved view by ID (`GET /api/v1/explorer/views/{viewId}`).
*
* Hand-maintained alongside the other `api/saveView/*` clients — explorer views
* are not in `docs/api/openapi.yml`, so Orval does not generate a hook here
* (unlike e.g. `useGetChannelByID` under `api/generated/services/channels`).
*
* Used by the AI assistant "Open view" action to load `compositeQuery` and
* navigate to the correct explorer without listing every view per source page.
* See `container/AIAssistant/components/ActionsSection/utils/openSavedView.ts`.
*/
export interface GetViewByIdProps {
status: string;
data: ViewProps;
}
export const getViewById = (
viewKey: string,
): Promise<AxiosResponse<GetViewByIdProps>> =>
axios.get(`/explorer/views/${viewKey}`);

View File

@@ -1,16 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
export const saveView = ({
compositeQuery,
sourcePage,
viewName,
extraData,
}: SaveViewProps): Promise<AxiosResponse<SaveViewPayloadProps>> =>
axios.post('/explorer/views', {
name: viewName,
sourcePage,
compositeQuery,
extraData,
});

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import {
UpdateViewPayloadProps,
UpdateViewProps,
} from 'types/api/saveViews/types';
export const updateView = ({
compositeQuery,
viewName,
extraData,
sourcePage,
viewKey,
}: UpdateViewProps): Promise<UpdateViewPayloadProps> =>
axios.put(`/explorer/views/${viewKey}`, {
name: viewName,
compositeQuery,
extraData,
sourcePage,
});

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#1F88C0" d="M11.094.47c-.842 0-1.696.092-2.552.288a11.37 11.37 0 0 0-4.87 2.423 10.632 10.632 0 0 0-2.36 2.826A10.132 10.132 0 0 0 .305 8.582c-.398 1.62-.4 3.336-.043 5.048.085.405.183.809.31 1.212a11.85 11.85 0 0 0 1.662 3.729 3.273 3.273 0 0 0-.086.427 3.323 3.323 0 0 0 2.848 3.71 3.279 3.279 0 0 0 1.947-.346c1.045.51 2.17.864 3.339 1.04a11.66 11.66 0 0 0 4.285-.155 11.566 11.566 0 0 0 4.936-2.485 10.643 10.643 0 0 0 2.352-2.894 11.164 11.164 0 0 0 1.356-4.424 11.214 11.214 0 0 0-.498-4.335c.175-.077.338-.175.486-.293a.444.444 89.992 0 0 .001 0c.402-.322.693-.794.777-1.342a2.146 2.146 0 0 0-1.79-2.434 2.115 2.115 0 0 0-1.205.171c-.038-.043-.078-.086-.113-.13a11.693 11.693 0 0 0-3.476-2.93 13.348 13.348 0 0 0-1.76-.81 13.55 13.55 0 0 0-2.06-.613A12.121 12.121 0 0 0 11.093.47Zm.714.328c.345-.004.688.01 1.028.042a9.892 9.892 0 0 1 2.743.639c.984.39 1.89.958 2.707 1.632.803.662 1.502 1.45 2.091 2.328.026.039.048.08.07.12a2.12 2.12 0 0 0-.435 2.646c-.158.114-.97.692-1.634 1.183-.414.308-.733.557-.733.557l.581.68s.296-.276.665-.638c.572-.562 1.229-1.233 1.395-1.403a2.122 2.122 0 0 0 1.907.677 11.229 11.229 0 0 1-.013 4.046 11.41 11.41 0 0 1-1.475 3.897 12.343 12.343 0 0 1-2.079 2.587c-1.19 1.125-2.633 2.022-4.306 2.531a10.826 10.826 0 0 1-3.973.484 11.04 11.04 0 0 1-3.057-.652 3.304 3.304 0 0 0 1.417-2.294 3.275 3.275 0 0 0-.294-1.842c.18-.162.403-.363.656-.6 1.015-.955 2.353-2.303 2.353-2.303l-.47-.599s-1.63.972-2.801 1.728c-.307.198-.573.378-.777.517a3.273 3.273 0 0 0-1.516-.611c-1.507-.198-2.927.672-3.487 2.017a10.323 10.323 0 0 1-.695-1.078A10.92 10.92 0 0 1 .728 14.8a10.35 10.35 0 0 1-.2-1.212c-.164-1.653.103-3.258.629-4.754a12.95 12.95 0 0 1 1.087-2.288c.57-.968 1.248-1.872 2.069-2.656A11.013 11.013 0 0 1 11.808.797Zm-.147 3.257a3.838 3.838 0 0 0-3.82 3.82v2.36h-.94c-.751 0-1.377.625-1.377 1.377v3.8h1.46v-3.718h9.354v6.264H10.02v1.46h6.4c.751 0 1.377-.625 1.377-1.377v-6.43c0-.751-.626-1.377-1.377-1.377h-.94v-2.36a3.838 3.838 0 0 0-3.82-3.819zm0 1.46a2.371 2.371 0 0 1 2.36 2.36v2.36H9.3v-2.36a2.372 2.372 0 0 1 2.36-2.36zm10.141.392a1.253 1.253 0 0 1 1.296 1.434c-.049.319-.217.59-.453.78-.266.213-.61.318-.968.264a1.253 1.253 0 0 1-1.045-1.42 1.255 1.255 0 0 1 1.17-1.058zM5.384 17.425a2.02 2.02 0 0 1 1.917 1.298c.116.3.159.628.114.967a2.015 2.015 0 0 1-2.249 1.728 2.016 2.016 0 0 1-1.727-2.25 2.017 2.017 0 0 1 1.945-1.743z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#E36002" d="M12.445.002a45.529 45.529 0 0 0-5.252 8.146 8.595 8.595 0 0 1-.555-.53 27.796 27.796 0 0 0-1.205-1.542 8.762 8.762 0 0 0-1.251 2.12 20.743 20.743 0 0 0-1.448 5.88 8.867 8.867 0 0 0 .338 3.468c1.312 3.48 3.794 5.593 7.445 6.337 3.055.438 5.755-.333 8.097-2.312 2.677-2.59 3.359-5.634 2.047-9.132a33.287 33.287 0 0 0-2.988-5.59A91.34 91.34 0 0 0 12.615.053a.216.216 0 0 0-.17-.051Zm-.336 3.906a50.93 50.93 0 0 1 4.794 6.552c.448.767.817 1.57 1.108 2.41.606 2.386-.044 4.354-1.951 5.904-1.845 1.298-3.87 1.683-6.072 1.156-2.376-.737-3.75-2.335-4.121-4.794a5.107 5.107 0 0 1 .242-2.266c.358-.908.79-1.774 1.3-2.601l1.446-2.121a397.33 397.33 0 0 0 3.254-4.24Z"/></svg>

After

Width:  |  Height:  |  Size: 758 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,43 +0,0 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { MessageSquareText } from '@signozhq/icons';
export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const { pathname } = useLocation();
return (
<>
<div className="chat-support-gateway">
<Button
className="chat-support-gateway-btn"
onClick={(): void => {
logEvent('Disabled Chat Support: Clicked', {
source: `chat support icon`,
page: pathname,
});
setIsAddCreditCardModalOpen(true);
}}
>
<MessageSquareText size={24} />
</Button>
</div>
<AddCreditCardModal
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={(): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
}}
/>
</>
);
}

View File

@@ -1,252 +0,0 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useUpdateView } from 'hooks/saveViews/useUpdateView';
import useErrorNotification from 'hooks/useErrorNotification';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { ExploreHeaderToolTip, SaveButtonText } from './constants';
import MenuItemGenerator from './MenuItemGenerator';
import SaveViewWithName from './SaveViewWithName';
import {
DropDownOverlay,
ExplorerCardHeadContainer,
OffSetCol,
} from './styles';
import { ExplorerCardProps } from './types';
import { deleteViewHandler } from './utils';
import { Ellipsis, Save, Share2, Trash2 } from '@signozhq/icons';
function ExplorerCard({
sourcepage,
children,
}: ExplorerCardProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [, setCopyUrl] = useCopyToClipboard();
const { notifications } = useNotifications();
const onCopyUrlHandler = (): void => {
setCopyUrl(window.location.href);
notifications.success({
message: 'Copied to clipboard',
});
};
const {
currentQuery,
panelType,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
isStagedQueryUpdated,
} = useQueryBuilder();
const {
data: viewsData,
isLoading,
error,
isRefetching,
refetch: refetchAllView,
} = useGetAllViews(sourcepage);
useErrorNotification(error);
const handleOpenChange = (newOpen = false): void => {
setIsOpen(newOpen);
};
const { viewName, viewKey } = useGetSavedViewParams();
const { options } = useOptionsMenu({
storageKey:
sourcepage === DataSource.TRACES
? LOCALSTORAGE.TRACES_LIST_OPTIONS
: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: sourcepage,
aggregateOperator: StringOperators.NOOP,
});
const isQueryUpdated = isStagedQueryUpdated(
viewsData?.data?.data,
viewKey,
options,
);
const { mutateAsync: updateViewAsync } = useUpdateView({
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
});
const { mutateAsync: deleteViewAsync } = useDeleteView(viewKey);
const showErrorNotification = (err: Error): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
const onDeleteHandler = (): void =>
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: viewKey,
viewKey,
updateAllQueriesOperators,
sourcePage: sourcepage,
});
const onUpdateQueryHandler = (): void => {
updateViewAsync(
{
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
},
{
onSuccess: () => {
notifications.success({
message: 'View Updated Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(err);
},
},
);
};
const moreOptionItems: DropdownItemType[] = [
{
type: 'item',
value: 'delete',
label: <Typography.Text strong>Delete</Typography.Text>,
onClick: onDeleteHandler,
prefix: <Trash2 size="md" />,
},
];
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
const saveButtonIcon = isQueryUpdated ? null : <Save size="md" />;
const showSaveView = false;
return (
<>
{showSaveView && (
<ExplorerCardHeadContainer size="small">
<Row align="middle">
<Col span={6}>
<Space>
<Typography>Query Builder</Typography>
<TextToolTip
url={ExploreHeaderToolTip.url}
text={ExploreHeaderToolTip.text}
useFilledIcon={false}
/>
</Space>
</Col>
<OffSetCol span={18}>
<Space size="large">
{viewsData?.data.data && viewsData?.data.data.length && (
<Space>
<Select
getPopupContainer={popupContainer}
loading={isLoading || isRefetching}
showSearch
placeholder="Select a view"
dropdownStyle={DropDownOverlay}
dropdownMatchSelectWidth={false}
optionLabelProp="value"
value={viewName || undefined}
>
{viewsData?.data.data.map((view) => (
<Select.Option key={view.id} value={view.name}>
<MenuItemGenerator
viewName={view.name}
viewKey={viewKey}
createdBy={view.createdBy}
uuid={view.id}
refetchAllView={refetchAllView}
viewData={viewsData.data.data}
sourcePage={sourcepage}
/>
</Select.Option>
))}
</Select>
</Space>
)}
{isQueryUpdated && (
<Button type="primary" icon={<Save />} onClick={onUpdateQueryHandler}>
Save changes
</Button>
)}
<Popover
getPopupContainer={popupContainer}
placement="bottomLeft"
trigger="click"
content={
<SaveViewWithName
sourcePage={sourcepage}
handlePopOverClose={handleOpenChange}
refetchAllView={refetchAllView}
/>
}
showArrow={false}
open={isOpen}
onOpenChange={handleOpenChange}
>
<Button
type={saveButtonType}
icon={saveButtonIcon}
data-testid="traces-save-view-action"
>
{isQueryUpdated
? SaveButtonText.SAVE_AS_NEW_VIEW
: SaveButtonText.SAVE_VIEW}
</Button>
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<Dropdown
items={moreOptionItems}
nativeButton
align="end"
side="bottom"
>
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
</Dropdown>
)}
</Space>
</OffSetCol>
</Row>
</ExplorerCardHeadContainer>
)}
<div>{children}</div>
</>
);
}
export default ExplorerCard;

View File

@@ -1,103 +0,0 @@
import { MouseEvent, useCallback } from 'react';
import { Trash2 } from '@signozhq/icons';
import { Col, Row, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { MenuItemContainer } from './styles';
import { MenuItemLabelGeneratorProps } from './types';
import {
deleteViewHandler,
getViewDetailsUsingViewKey,
trimViewName,
} from './utils';
function MenuItemGenerator({
viewName,
viewKey,
createdBy,
uuid,
viewData,
refetchAllView,
sourcePage,
}: MenuItemLabelGeneratorProps): JSX.Element {
const { panelType, redirectWithQueryBuilderData, updateAllQueriesOperators } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { notifications } = useNotifications();
const { mutateAsync: deleteViewAsync } = useDeleteView(uuid);
const onDeleteHandler = (event: MouseEvent<SVGSVGElement>): void => {
event.stopPropagation();
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: uuid,
viewKey,
updateAllQueriesOperators,
sourcePage,
});
};
const onMenuItemSelectHandler = useCallback(
({ key }: { key: string }): void => {
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
if (!currentViewDetails) {
return;
}
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
handleExplorerTabChange(currentPanelType, {
query,
viewName: name,
viewKey: id,
});
},
[viewData, handleExplorerTabChange],
);
const onLabelClickHandler = (): void => {
onMenuItemSelectHandler({
key: uuid,
});
};
const newViewName = trimViewName(viewName);
return (
<MenuItemContainer onClick={onLabelClickHandler}>
<Row justify="space-between">
<Col span={22}>
<Row>
<Tooltip title={viewName}>
<Typography.Text strong>{newViewName}</Typography.Text>
</Tooltip>
</Row>
<Row>
<Typography.Text color="muted">Created by {createdBy}</Typography.Text>
</Row>
</Col>
<Col span={2}>
<Typography.Link>
<Trash2
role="img"
aria-label="Delete view"
onClick={onDeleteHandler}
size="md"
/>
</Typography.Link>
</Col>
</Row>
</MenuItemContainer>
);
}
export default MenuItemGenerator;

View File

@@ -1,79 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Card, Form } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveView } from 'hooks/saveViews/useSaveView';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { SaveButton } from './styles';
import { SaveViewFormProps, SaveViewWithNameProps } from './types';
import { saveViewHandler } from './utils';
function SaveViewWithName({
sourcePage,
handlePopOverClose,
refetchAllView,
}: SaveViewWithNameProps): JSX.Element {
const [form] = Form.useForm<SaveViewFormProps>();
const { t } = useTranslation(['explorer']);
const { currentQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { notifications } = useNotifications();
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
const { isLoading, mutateAsync: saveViewAsync } = useSaveView({
viewName: form.getFieldValue('viewName'),
compositeQuery,
sourcePage,
extraData: '',
});
const onSaveHandler = (): void => {
saveViewHandler({
compositeQuery,
handlePopOverClose,
extraData: '',
notifications,
panelType: panelType || PANEL_TYPES.LIST,
redirectWithQueryBuilderData,
refetchAllView,
saveViewAsync,
sourcePage,
viewName: form.getFieldValue('viewName'),
form,
});
};
return (
<Card>
<Typography>{t('name_of_the_view')}</Typography>
<Form form={form} onFinish={onSaveHandler} requiredMark>
<Form.Item
name={['viewName']}
required
rules={[
{
required: true,
message: 'Please enter view name',
},
]}
>
<Input placeholder="Enter Name" />
</Form.Item>
<SaveButton
htmlType="submit"
type="primary"
loading={isLoading}
data-testid="save-view-name-action-button"
>
Save
</SaveButton>
</Form>
</Card>
);
}
export default SaveViewWithName;

View File

@@ -1,32 +0,0 @@
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const viewMockData: ViewProps[] = [
{
id: 'view1',
name: 'View 1',
createdBy: 'User 1',
category: 'category 1',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:31:00.000Z',
updatedAt: '2021-07-07T06:33:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 1',
},
{
id: 'view2',
name: 'View 2',
createdBy: 'User 2',
category: 'category 2',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:30:00.000Z',
updatedAt: '2021-07-07T06:30:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 2',
},
];

View File

@@ -1,9 +0,0 @@
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};
export const SaveButtonText = {
SAVE_AS_NEW_VIEW: 'Save as new view',
SAVE_VIEW: 'Save view',
};

View File

@@ -1,29 +0,0 @@
import { Button, Card, Col } from 'antd';
import styled, { CSSProperties } from 'styled-components';
export const ExplorerCardHeadContainer = styled(Card)`
margin: 1rem 0;
padding: 0;
`;
export const OffSetCol = styled(Col)`
text-align: right;
`;
export const SaveButton = styled(Button)`
&&& {
margin: 1rem 0;
width: 5rem;
}
`;
export const DropDownOverlay: CSSProperties = {
maxHeight: '20rem',
overflowY: 'auto',
width: '20rem',
padding: 0,
};
export const MenuItemContainer = styled(Card)`
padding: 0;
`;

View File

@@ -1,96 +0,0 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
import { viewMockData } from '../__mock__/viewData';
import ExplorerCard from '../ExplorerCard';
const historyReplace = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
useHistory: (): any => ({
...jest.requireActual('react-router-dom').useHistory(),
replace: historyReplace,
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useGetAllViews', () => ({
useGetAllViews: jest.fn(() => ({
data: { data: { data: viewMockData } },
isLoading: false,
error: null,
isRefetching: false,
refetch: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useUpdateView', () => ({
useUpdateView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useDeleteView', () => ({
useDeleteView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
// Mock usePreferenceSync
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
describe('ExplorerCard', () => {
it('renders a card with a title and a description', () => {
render(
<MockQueryClientProvider>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Query Builder')).not.toBeInTheDocument();
});
it('renders a save view button', () => {
render(
<MockQueryClientProvider>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Save view')).not.toBeInTheDocument();
});
});

View File

@@ -1,63 +0,0 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
import { viewMockData } from '../__mock__/viewData';
import MenuItemGenerator from '../MenuItemGenerator';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
jest.mock('antd', () => ({
...jest.requireActual('antd'),
useForm: jest.fn().mockReturnValue({
onFinish: jest.fn(),
}),
}));
describe('MenuItemGenerator', () => {
it('should render MenuItemGenerator component', () => {
const screen = render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText(viewMockData[0].name)).toBeInTheDocument();
});
it('should call onMenuItemSelectHandler on click of MenuItemGenerator', () => {
render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}
/>
</MockQueryClientProvider>,
);
const spanElement = screen.getByRole('img', {
name: /delete view/i,
});
expect(spanElement).toBeInTheDocument();
});
});

View File

@@ -1,63 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { fireEvent, render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SaveViewWithName from '../SaveViewWithName';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useSaveView', () => ({
useSaveView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
describe('SaveViewWithName', () => {
it('should render SaveViewWithName component', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('should call saveViewAsync on click of Save button', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
fireEvent.click(screen.getByText('Save'));
expect(screen.getByText('Save')).toBeInTheDocument();
});
});

View File

@@ -1,92 +0,0 @@
import { UseMutateAsyncFunction } from 'react-query';
import { FormInstance } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { AxiosResponse } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
DeleteViewPayloadProps,
SaveViewPayloadProps,
SaveViewProps,
ViewProps,
} from 'types/api/saveViews/types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
export interface ExplorerCardProps {
sourcepage: DataSource;
children: React.ReactNode;
}
export type GetViewDetailsUsingViewKey = (
viewKey: string,
data: ViewProps[] | undefined,
) =>
| {
query: Query;
name: string;
id: string;
panelType: PANEL_TYPES;
extraData?: string;
}
| undefined;
export interface IsQueryUpdatedInViewProps {
viewKey: string;
data: ViewProps[] | undefined;
stagedQuery: Query | null;
currentPanelType: PANEL_TYPES | null;
options: OptionsQuery;
}
export interface SaveViewWithNameProps {
sourcePage: ExplorerCardProps['sourcepage'];
handlePopOverClose: VoidFunction;
refetchAllView: VoidFunction;
}
export interface SaveViewFormProps {
viewName: string;
}
export interface MenuItemLabelGeneratorProps {
viewName: string;
viewKey: string;
createdBy: string;
uuid: string;
viewData: ViewProps[];
refetchAllView: VoidFunction;
sourcePage: ExplorerCardProps['sourcepage'];
}
export interface SaveViewHandlerProps {
viewName: string;
compositeQuery: ICompositeMetricQuery;
sourcePage: ExplorerCardProps['sourcepage'];
extraData: string;
panelType: PANEL_TYPES | null;
notifications: NotificationInstance;
refetchAllView: SaveViewWithNameProps['refetchAllView'];
saveViewAsync: UseMutateAsyncFunction<
AxiosResponse<SaveViewPayloadProps>,
Error,
SaveViewProps,
SaveViewPayloadProps
>;
handlePopOverClose: SaveViewWithNameProps['handlePopOverClose'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
form: FormInstance<SaveViewFormProps>;
}
export interface DeleteViewHandlerProps {
deleteViewAsync: UseMutateAsyncFunction<DeleteViewPayloadProps, Error, string>;
refetchAllView: MenuItemLabelGeneratorProps['refetchAllView'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
notifications: NotificationInstance;
panelType: PANEL_TYPES | null;
viewKey: string;
viewId: string;
updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'];
sourcePage: ExplorerCardProps['sourcepage'];
}

View File

@@ -1,204 +0,0 @@
import type { NotificationInstance } from 'antd/es/notification/interface';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import isEqual from 'lodash-es/isEqual';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
DeleteViewHandlerProps,
GetViewDetailsUsingViewKey,
IsQueryUpdatedInViewProps,
SaveViewHandlerProps,
} from './types';
export const showErrorNotification = (
notifications: NotificationInstance,
err: Error,
): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
viewKey,
data,
) => {
const selectedView = data?.find((view) => view.id === viewKey);
if (selectedView) {
const { compositeQuery, name, id, extraData } = selectedView;
const query = mapQueryDataFromApi(compositeQuery);
return { query, name, id, panelType: compositeQuery.panelType, extraData };
}
return undefined;
};
export const omitIdFromQuery = (query: Query | null): any => ({
...query,
builder: {
...query?.builder,
queryData: query?.builder.queryData.map((queryData) => {
const { id: _aggregateAttributeId, ...rest } =
queryData.aggregateAttribute || {};
const newAggregateAttribute = rest;
const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => {
const { id: _groupByAttributeId, ...rest } = groupByAttribute;
return rest;
});
const newItems = queryData.filters?.items?.map((item) => {
const { id: _itemId, ...newItem } = item;
if (item.key) {
const { id: _keyId, ...rest } = item.key;
return {
...newItem,
key: rest,
};
}
return newItem;
});
return {
...queryData,
aggregateAttribute: newAggregateAttribute,
groupBy: newGroupByAttributes,
filters: {
...queryData.filters,
items: newItems,
},
limit: queryData.limit ? queryData.limit : 0,
offset: queryData.offset ? queryData.offset : 0,
pageSize: queryData.pageSize ? queryData.pageSize : 0,
};
}),
},
});
export const isQueryUpdatedInView = ({
viewKey,
data,
stagedQuery,
currentPanelType,
options,
}: IsQueryUpdatedInViewProps): boolean => {
const currentViewDetails = getViewDetailsUsingViewKey(viewKey, data);
if (!currentViewDetails) {
return false;
}
const { query, panelType, extraData } = currentViewDetails;
// Omitting id from aggregateAttribute and groupBy
const updatedCurrentQuery = omitIdFromQuery(stagedQuery);
if (
updatedCurrentQuery?.builder === undefined ||
updatedCurrentQuery.clickhouse_sql === undefined ||
updatedCurrentQuery.promql === undefined
) {
return false;
}
return (
panelType !== currentPanelType ||
!isEqual(query.builder, updatedCurrentQuery?.builder) ||
!isEqual(query.clickhouse_sql, updatedCurrentQuery?.clickhouse_sql) ||
!isEqual(query.promql, updatedCurrentQuery?.promql) ||
!isEqual(
options?.selectColumns,
extraData && JSON.parse(extraData)?.selectColumns,
) ||
(stagedQuery?.builder?.queryData?.[0]?.dataSource === DataSource.LOGS &&
(!isEqual(options?.format, extraData && JSON.parse(extraData)?.format) ||
!isEqual(options?.maxLines, extraData && JSON.parse(extraData)?.maxLines) ||
!isEqual(options?.fontSize, extraData && JSON.parse(extraData)?.fontSize)))
);
};
export const saveViewHandler = ({
saveViewAsync,
refetchAllView,
notifications,
handlePopOverClose,
viewName,
compositeQuery,
sourcePage,
extraData,
redirectWithQueryBuilderData,
panelType,
form,
}: SaveViewHandlerProps): void => {
saveViewAsync(
{
viewName,
compositeQuery,
sourcePage,
extraData,
},
{
onSuccess: (data) => {
refetchAllView();
redirectWithQueryBuilderData(mapQueryDataFromApi(compositeQuery), {
[QueryParams.panelTypes]: panelType,
[QueryParams.viewName]: viewName,
[QueryParams.viewKey]: data.data.data,
});
notifications.success({
message: 'View Saved Successfully',
});
},
onError: (err) => {
showErrorNotification(notifications, err);
},
onSettled: () => {
handlePopOverClose();
form.resetFields();
},
},
);
};
export const deleteViewHandler = ({
deleteViewAsync,
refetchAllView,
redirectWithQueryBuilderData,
notifications,
panelType,
viewKey,
viewId,
updateAllQueriesOperators,
sourcePage,
}: DeleteViewHandlerProps): void => {
deleteViewAsync(viewKey, {
onSuccess: () => {
if (viewId === viewKey) {
redirectWithQueryBuilderData(
updateAllQueriesOperators(
initialQueriesMap[sourcePage],
panelType || PANEL_TYPES.LIST,
sourcePage,
),
{
[QueryParams.viewName]: '',
[QueryParams.panelTypes]: panelType,
[QueryParams.viewKey]: '',
},
);
}
notifications.success({
message: 'View Deleted Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(notifications, err);
},
});
};
export const trimViewName = (viewName: string): string => {
if (viewName.length > 20) {
return `${viewName.substring(0, 20)}...`;
}
return viewName;
};

View File

@@ -1,5 +1,5 @@
import { DrawerProps } from 'antd';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -4,7 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import {
getBodyDisplayString,
getSanitizedLogBody,

View File

@@ -1,6 +1,6 @@
import { MouseEvent } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -1,14 +1,9 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -64,20 +59,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
}
handleSetConfig(newPanelType, currentDataSource);
}
}, [
@@ -86,7 +69,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {

View File

@@ -130,7 +130,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
updateAllQueriesOperators,
updateQueriesData,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
} as unknown as QueryBuilderContextType;

View File

@@ -136,7 +136,6 @@ describe('MetricsSelect - signal source switching (standalone)', () => {
updateAllQueriesOperators: ((q: any) => q) as any,
updateQueriesData: ((q: any) => q) as any,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
});
});
@@ -293,7 +292,6 @@ describe('DataSource change - Logs to Traces', () => {
updateAllQueriesOperators: ((q: any) => q) as any,
updateQueriesData: ((q: any) => q) as any,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
});
});

View File

@@ -0,0 +1,109 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render } from 'tests/test-utils';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const aiQuery: Query = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
...query,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
})),
},
};
function mockContext({
panelType = null,
initialDataSource = null,
currentQuery = initialQueriesMap.metrics,
}: {
panelType?: PANEL_TYPES | null;
initialDataSource?: DataSource | null;
currentQuery?: Query;
}): jest.Mock {
const handleSetConfig = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType,
initialDataSource,
handleSetConfig,
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
addTraceOperator: jest.fn(),
handleRunQuery: jest.fn(),
} as unknown as ReturnType<typeof useQueryBuilder>);
return handleSetConfig;
}
describe('QueryBuilderV2 provider config sync', () => {
it('leaves the data source unset for a bar panel without a static config', () => {
const handleSetConfig = mockContext({});
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
});
it('syncs the static data source', () => {
const handleSetConfig = mockContext({});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.LOGS,
);
});
it('keeps an AI query on traces for a bar panel', () => {
const handleSetConfig = mockContext({ currentQuery: aiQuery });
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.TRACES,
);
});
it('does not reset the config when the provider already matches', () => {
const handleSetConfig = mockContext({
panelType: PANEL_TYPES.TIME_SERIES,
initialDataSource: DataSource.TRACES,
});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.TIME_SERIES}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).not.toHaveBeenCalled();
});
});

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

@@ -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,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 {
@@ -21,7 +20,6 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
};
@@ -158,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

@@ -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,13 +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 [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
const {
isOpen,
@@ -75,7 +80,7 @@ export default function CheckboxFilterV2(
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
source,
quickFilterSource,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
@@ -104,7 +109,7 @@ export default function CheckboxFilterV2(
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
quickFilterSource,
attributeValues,
activeQueryIndex,
onFilterChange,

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

@@ -72,7 +72,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -101,7 +101,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -119,7 +119,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -173,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"',
@@ -240,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"',
@@ -288,7 +288,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -330,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"',
@@ -394,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"',
@@ -440,7 +440,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -468,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}
/>,
);
@@ -490,7 +490,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -516,7 +516,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -568,7 +568,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -601,7 +601,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -627,7 +627,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -652,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,
@@ -691,7 +691,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -714,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}
/>,
@@ -761,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}
/>,
@@ -837,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}
/>,
@@ -971,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

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -47,7 +47,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={closedFilter}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -104,7 +104,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -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

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

@@ -49,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;
@@ -68,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,
@@ -112,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 => {
@@ -290,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
@@ -313,7 +316,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
source={source}
quickFilterSource={quickFilterSource}
filter={filter}
onFilterChange={onFilterChange}
/>
@@ -324,7 +327,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
quickFilterSource={quickFilterSource}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
@@ -363,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}
@@ -391,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}
@@ -407,7 +410,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
showFilterCollapse: true,
showQueryName: true,

View File

@@ -5,7 +5,6 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
@@ -50,10 +49,9 @@ function QuickFiltersLayout({
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const hasSavedViews = !!savedViewProps;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);

View File

@@ -1,6 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
@@ -20,10 +19,6 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
@@ -34,17 +29,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 +57,7 @@ jest.mock('../../QuickFilters', () => ({
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
@@ -71,10 +66,6 @@ const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -149,35 +140,6 @@ describe('QuickFiltersLayout', () => {
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout

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

@@ -89,6 +89,15 @@ export const handlers = [
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

@@ -15,8 +15,9 @@ import {
loadingFiltersHandlers,
queryBuilder,
selectedServiceQueryBuilder,
noSavedFiltersHandler,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersSource } from '../types';
const meta = {
title: 'Components/Quick Filters',
@@ -33,8 +34,7 @@ const meta = {
args: {
config: checkboxConfig,
handleFilterVisibilityChange: (): void => undefined,
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
quickFilterSource: QuickFiltersSource.LOGS_EXPLORER,
useFieldApis: fieldApis,
},
parameters: {
@@ -94,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,
},
@@ -111,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: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
msw: {
handlers: [
noSavedFiltersHandler,
fieldValuesHandler(LONG_FILTER_VALUES),
...handlers,
],
},
},
};

View File

@@ -24,7 +24,7 @@ import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersSource } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -35,8 +35,7 @@ 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 fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -81,11 +80,10 @@ 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}
/>,
@@ -95,7 +93,7 @@ function renderWithSignal(): void {
function renderStaticConfig(): void {
render(
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
@@ -108,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),
@@ -133,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),
@@ -158,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),
@@ -176,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());
@@ -195,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,8 +1,11 @@
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,
@@ -16,7 +19,7 @@ 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 { IQuickFiltersConfig, QuickFiltersSource } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -33,9 +36,9 @@ 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 fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -54,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)),
),
@@ -68,48 +74,24 @@ const setupServer = (): void => {
};
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}
useFieldApis={QuickFiltersFieldApis}
signal={signal}
/>
);
}
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}
useFieldApis={QuickFiltersFieldApis}
signal={signal}
/>
);
}
TestQuickFiltersApiMonitoring.defaultProps = {
signal: '',
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
config: QuickFiltersConfig,
};
@@ -287,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');
@@ -308,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();
@@ -368,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);
@@ -396,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);
@@ -417,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);
@@ -446,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);
@@ -488,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);
@@ -525,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();
@@ -579,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(
@@ -589,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();
@@ -604,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 () => {
@@ -635,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),
@@ -687,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),
@@ -718,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,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,11 +43,9 @@ 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;
@@ -65,15 +53,19 @@ export interface IQuickFiltersProps {
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];
export type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: 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

@@ -21,7 +21,6 @@ export enum LOCALSTORAGE {
CHAT_SUPPORT = 'CHAT_SUPPORT',
IS_IDENTIFIED_USER = 'IS_IDENTIFIED_USER',
DASHBOARD_VARIABLES = 'DASHBOARD_VARIABLES',
SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR',
PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES',
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS',
@@ -47,5 +46,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -50,8 +50,6 @@ const ROUTES = {
ROLE_EDIT: '/settings/roles/:roleId/edit',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',
TRACES_FUNNELS: '/traces/funnels',
TRACES_FUNNELS_DETAIL: '/traces/funnels/:funnelId',
WORKSPACE_LOCKED: '/workspace-locked',
@@ -69,7 +67,6 @@ const ROUTES = {
MESSAGING_QUEUES_OVERVIEW: '/messaging-queues/overview',
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
@@ -77,7 +74,6 @@ const ROUTES = {
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
METER: '/meter',
METER_EXPLORER: '/meter/explorer',
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',

View File

@@ -1,15 +0,0 @@
.trigger {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
transition: transform 0.15s;
&:hover {
transform: scale(1.08);
}
&:active {
transform: scale(0.96);
}
}

View File

@@ -1,61 +0,0 @@
import { useCallback } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
} from '../store/useAIAssistantStore';
import styles from './AIAssistantTrigger.module.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.Icon,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<div className={`${styles.trigger} noz-wave`}>
<Tooltip title={NOZ_TOOLTIP_TITLE}>
<Button
size="md"
variant="solid"
color="primary"
icon
onClick={handleOpen}
aria-label="Open Noz"
>
<Noz size={24} />
</Button>
</Tooltip>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './AIAssistantTrigger';
export { default } from './AIAssistantTrigger';

View File

@@ -102,7 +102,7 @@ describe('resolvePageType', () => {
expect(resolvePageType(ROUTES.METRICS_EXPLORER_EXPLORER, '')).toBe(
PageTypeDTO.metrics_explorer,
);
expect(resolvePageType(ROUTES.METRICS_EXPLORER_VIEWS, '')).toBe(
expect(resolvePageType('/metrics-explorer/views', '')).toBe(
PageTypeDTO.metrics_explorer,
);
});

View File

@@ -235,7 +235,6 @@ describe('buildExplorerNavigationUrl', () => {
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewName]: 'My view',
[QueryParams.viewKey]: 'view-1',
},
);
@@ -259,7 +258,7 @@ describe('openSavedView', () => {
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
const params = new URLSearchParams(pushedUrl.split('?')[1]);
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
expect(params.get(QueryParams.viewName)).toBeNull();
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
});

View File

@@ -9,7 +9,7 @@ import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { History } from 'history';
@@ -108,7 +108,6 @@ export function openSavedView(
const query = getSavedViewQuery(view);
const url = buildExplorerNavigationUrl(route, query, {
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
[QueryParams.viewName]: view.spec.displayName,
[QueryParams.viewKey]: view.id,
});
history.push(url);

View File

@@ -13,7 +13,7 @@ import { matchPath } from 'react-router-dom';
*
* **URL-only.** All inputs come from `pathname` + `search`; we never reach
* into Redux / context. SigNoz already encodes most page state into the URL
* (`compositeQuery`, `startTime`/`endTime`, `viewName`/`viewKey`,
* (`compositeQuery`, `startTime`/`endTime`, `viewKey`,
* `variables`, `expandedWidgetId`, `activeLogId`, …), so URL parsing is
* sufficient. Anything not in the URL is simply omitted from `metadata` —
* the server applies its own defaults.
@@ -288,7 +288,7 @@ export function getAutoContexts(
// ── Metrics ───────────────────────────────────────────────────────────────
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer, views).
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer).
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
) {
@@ -361,10 +361,6 @@ function collectSharedMetadata(
if (viewKey) {
out.savedViewId = viewKey;
}
const viewName = params.get(QueryParams.viewName);
if (viewName) {
out.savedViewName = viewName;
}
const variablesRaw = params.get(QueryParams.variables);
if (variablesRaw) {
try {

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

@@ -72,40 +72,6 @@
}
}
.chat-support-gateway {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;
.chat-support-gateway-btn {
max-width: 48px;
width: 48px;
max-height: 48px;
height: 48px;
padding: 12px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: #f25733;
border: none;
&:hover {
color: white !important;
border-color: white !important;
}
.chat-support-gateway-btn-icon {
fill: white;
}
}
}
.add-credit-card-btn,
.cancel-btn {
display: inline-flex;

View File

@@ -28,7 +28,6 @@ import getUserLatestVersion from 'api/v1/version/getLatestVersion';
import { AxiosError } from 'axios';
import cx from 'classnames';
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
@@ -51,8 +50,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -401,7 +398,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -489,8 +485,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, [isDarkMode]);
const chatSupport = useChatSupport();
// Listen for API warnings
const handleWarning = (
isSlow: boolean,
@@ -834,9 +828,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
{isToDisplayLayout && !renderFullScreen && <BottomStrip />}
</div>
{isLoggedIn && isAIAssistantEnabled && (
@@ -847,9 +839,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
</Flex>
{chatSupport === ChatSupportState.NeedsCard && !isSavedViewEnabled && (
<ChatSupportGateway />
)}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}

View File

@@ -2,7 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
@@ -56,6 +56,22 @@ describe('toChannelConfig', () => {
});
});
it.each([
[WebhookAuth.none, {}],
[WebhookAuth.basic, { username: 'u', password: 'p' }],
[WebhookAuth.bearer, { bearerToken: 't' }],
])('sends only the %s webhook credentials', (webhookAuth, credentials) => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
webhookAuth,
username: 'u',
password: 'p',
bearerToken: 't',
}).spec,
).toStrictEqual({ url: 'https://example.com/hook', ...credentials });
});
it('drops an attachment list the user emptied instead of sending []', () => {
expect(
toChannelConfig(ChannelKind.slack, {

View File

@@ -18,8 +18,17 @@ import {
import { omit, pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration, filledRows } from './utils';
import {
ChannelFormValues,
ChannelKind,
ChannelSpecFormValues,
WebhookAuth,
} from './types';
import {
canonicalJiraReopenDuration,
filledRows,
webhookAuthOf,
} from './utils';
const SPEC_FIELDS = {
[ChannelKind.slack]: [
@@ -210,6 +219,17 @@ export function toChannelConfig(
);
}
if (kind === ChannelKind.webhook) {
const auth = webhookAuthOf(values);
if (auth !== WebhookAuth.basic) {
delete picked.username;
delete picked.password;
}
if (auth !== WebhookAuth.bearer) {
delete picked.bearerToken;
}
}
const spec: Record<string, unknown> = omitBlank(picked);
// the API answers a read with the duration prometheus would print, and

View File

@@ -1,6 +1,7 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
import { webhookAuthOf } from './utils';
export interface ChannelFormState {
kind: ChannelKind;
@@ -22,11 +23,15 @@ function toChannelKind(kind: string): ChannelKind {
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: toChannelKind(channel.config.kind),
values: {
...channel.config.spec,
name: channel.displayName,
},
const kind = toChannelKind(channel.config.kind);
const values: ChannelFormValues = {
...channel.config.spec,
name: channel.displayName,
};
if (kind === ChannelKind.webhook) {
values.webhookAuth = webhookAuthOf(values);
}
return { kind, values };
}

View File

@@ -1,4 +1,5 @@
import { TFunction } from 'i18next';
import { get, set } from 'lodash-es';
import type { FieldErrors, Resolver } from 'react-hook-form';
import type { $ZodIssue } from 'zod/v4/core';
@@ -10,12 +11,12 @@ const toFieldErrors = (
issues: $ZodIssue[],
t: TFunction,
): FieldErrors<ChannelFormValues> => {
const errors: Record<string, { type: string; message: string }> = {};
const errors = {};
issues.forEach((issue) => {
const field = issue.path.join('.') || 'name';
if (!errors[field]) {
errors[field] = { type: 'validation', message: t(issue.message) };
const path = issue.path.length ? issue.path : ['name'];
if (!get(errors, path)) {
set(errors, path, { type: 'validation', message: t(issue.message) });
}
});

View File

@@ -23,7 +23,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {

View File

@@ -1,11 +1,7 @@
import { z } from 'zod';
import { isValidGoogleChatWebhookURL } from '../utils';
import { requiredString } from './shared';
export const googleChatSchema = z.object({
webhookUrl: requiredString('webhook_url_required').refine(
isValidGoogleChatWebhookURL,
{ message: 'google_chat_webhook_url_invalid' },
),
webhookUrl: requiredString('webhook_url_required'),
});

View File

@@ -1,13 +1,19 @@
import { z } from 'zod';
import { isValidIncidentIOURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('incidentio_required_fields');
export const incidentIOSchema = z.object({
url: required.refine(isValidIncidentIOURL, {
message: 'incidentio_url_invalid',
}),
token: required,
url: required,
token: z
.string({ error: 'incidentio_required_fields' })
.refine((token) => token === token.trim(), {
message: 'incidentio_token_whitespace',
})
.pipe(
required.refine((token) => !/^bearer( |$)/i.test(token), {
message: 'incidentio_token_bearer_prefix',
}),
),
});

View File

@@ -1,12 +1,12 @@
import { z } from 'zod';
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
import { isValidJiraReopenDuration } from '../utils';
import { requiredString } from './shared';
const required = requiredString('jira_required_fields');
export const jiraSchema = z.object({
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
site: required,
email: required,
apiToken: required,
project: required,

View File

@@ -2,6 +2,30 @@ import { z } from 'zod';
import { requiredString } from './shared';
// rows with a blank title or text are dropped on save, so only the rest are checked
const fieldRow = z
.object({ title: z.string().optional(), value: z.string().optional() })
.refine((row) => !row.title?.trim() || !!row.value?.trim(), {
message: 'slack_field_value_required',
path: ['value'],
});
const actionRow = z
.object({
text: z.string().optional(),
url: z.string().optional(),
name: z.string().optional(),
})
.refine(
(row) => !row.text?.trim() || !!row.url?.trim() || !!row.name?.trim(),
{
message: 'slack_action_url_or_name_required',
path: ['url'],
},
);
export const slackSchema = z.object({
apiUrl: requiredString('webhook_url_required'),
fields: z.array(fieldRow).nullish(),
actions: z.array(actionRow).nullish(),
});

View File

@@ -1,16 +1,26 @@
import { z } from 'zod';
import { WebhookAuth } from '../types';
import { webhookAuthOf } from '../utils';
import { requiredString } from './shared';
export const webhookSchema = z
.object({
url: requiredString('webhook_url_required'),
webhookAuth: z.enum(WebhookAuth).optional(),
username: z.string().optional(),
password: z.string().optional(),
bearerToken: z.string().optional(),
})
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
.refine((v) => !v.username || !!v.password, {
message: 'username_no_password',
path: ['password'],
});
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.basic ||
!!values.username?.trim() ||
!!values.password?.trim(),
{ message: 'webhook_basic_auth_required', path: ['username'] },
)
.refine(
(values) =>
webhookAuthOf(values) !== WebhookAuth.bearer || !!values.bearerToken?.trim(),
{ message: 'webhook_bearer_token_required', path: ['bearerToken'] },
);

View File

@@ -31,6 +31,15 @@ export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelTelegramConfigDTO
>;
export const WebhookAuth = {
none: 'none',
basic: 'basic',
bearer: 'bearer',
} as const;
export type WebhookAuth = (typeof WebhookAuth)[keyof typeof WebhookAuth];
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
/** Never sent: the API reads the mode from which credentials are set. */
webhookAuth?: WebhookAuth;
};

View File

@@ -1,29 +1,4 @@
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' &&
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
);
} catch {
return false;
}
};
import { ChannelFormValues, WebhookAuth } from './types';
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
@@ -94,21 +69,21 @@ export const canonicalJiraReopenDuration = (value: string): string => {
}, '');
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
// the backend enforces the same rule, this is only for a nicer error experience
export const isValidIncidentIOURL = (url: string): boolean => {
try {
const { protocol, pathname } = new URL(url);
const idx = pathname.indexOf(INCIDENTIO_EVENTS_PATH_PREFIX);
return (
protocol === 'https:' &&
idx !== -1 &&
pathname.length > idx + INCIDENTIO_EVENTS_PATH_PREFIX.length
);
} catch {
return false;
export const webhookAuthOf = (
values: Pick<
ChannelFormValues,
'webhookAuth' | 'username' | 'password' | 'bearerToken'
>,
): WebhookAuth => {
if (values.webhookAuth) {
return values.webhookAuth;
}
if (values.bearerToken) {
return WebhookAuth.bearer;
}
return values.username || values.password
? WebhookAuth.basic
: WebhookAuth.none;
};
export const filledRows = <T>(

View File

@@ -0,0 +1,93 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import { validateChannel } from './validation';
const t = ((key: string) => key) as TFunction;
describe('validateChannel', () => {
it.each<[string, ChannelKind, ChannelFormValues, string | null]>([
[
'a google chat webhook behind a proxy',
ChannelKind.googlechat,
{ webhookUrl: 'http://relay.internal/chat' },
null,
],
[
'a jira site that is not on atlassian.net',
ChannelKind.jira,
{
site: 'https://jira-proxy.acme.com/',
email: 'a@acme.com',
apiToken: 't',
project: 'OPS',
issueType: 'Bug',
},
null,
],
[
'any incident.io url',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok' },
null,
],
[
'an incident.io token pasted with its Bearer prefix',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'Bearer tok' },
'incidentio_token_bearer_prefix',
],
[
'an incident.io token with a trailing space',
ChannelKind.incidentio,
{ url: 'https://relay.internal/incidentio', token: 'tok ' },
'incidentio_token_whitespace',
],
[
'a webhook username without a password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic, username: 'u' },
null,
],
[
'basic auth with neither username nor password',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.basic },
'webhook_basic_auth_required',
],
[
'bearer auth without a token',
ChannelKind.webhook,
{ url: 'https://hook', webhookAuth: WebhookAuth.bearer, username: 'u' },
'webhook_bearer_token_required',
],
[
'a slack field without a value',
ChannelKind.slack,
{ apiUrl: 'https://hooks', fields: [{ title: 'env', value: '' }] },
'slack_field_value_required',
],
[
'a slack button with neither url nor name',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Runbook', url: '' }],
},
'slack_action_url_or_name_required',
],
[
'a slack button with a name only',
ChannelKind.slack,
{
apiUrl: 'https://hooks',
actions: [{ type: 'button', text: 'Ack', name: 'ack' }],
},
null,
],
])('%s', (_, kind, values, expected) => {
expect(validateChannel(kind, { name: 'channel', ...values }, t)).toBe(
expected,
);
});
});

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