Compare commits

..

18 Commits

Author SHA1 Message Date
Naman Verma
16efc6a01c Merge branch 'main' into nv/distr-query 2026-11-03 16:51:14 +05:30
Naman Verma
d36c4ba843 fix: stringvalue function on request type 2026-11-03 16:50:55 +05:30
Vinicius Lourenço
b76a566f14 test(e2e): rebuild the suite on a driver kit (#13189)
#### Description

- Adds a driver kit in `tests/e2e/drivers/`: one module per feature that
owns its locators, seeding and navigation. A spec imports one module and
calls no `page.locator`/`getBy*` itself. `tests/e2e/helpers/` and
`tests/e2e/fixtures/` are removed.
- Moves the existing specs onto the kit and adds specs for alerts, logs,
traces, APM, metrics and meter, infra monitoring, integrations, LLM
observability, settings, auth, SSO, onboarding and the app shell. 86
spec files become 125.
- Keeps the dashboards suite on `main` and its drivers. Adds only what
it lacks: row rename, tags and duplicate, section clone and delete,
stack modes, viewer permissions and public dashboards. The channel specs
on `main` stay as well.
- Checks that need no browser go to jest or pytest integration tests
instead, following the layer rule in `tests/e2e/AGENTS.md`.
- Fixes three frontend bugs the suite hit:
- the logs explorer requested the next infinite-scroll page again when
the end was reached before it loaded
- the side nav showed the default pinned shortcuts instead of the saved
ones when user preferences loaded late
- TanStack tables read `undefined` from column state saved by an older
version
- Adds the testids the specs select by, and turns on metrics reduction,
which the bootstrap conftest had been shadowing. Known flakes and their
workarounds are in `tests/e2e/docs/flake-notes.md`.

| Playwright (chromium) | `main` | This PR |
| --- | --- | --- |
| Tests | 425 | 647 |
| Spec files that run | 86 | 125 |
| Feature areas with specs | 9 | 27 |

- Dashboards goes from 185 tests to 196.
- Alerts goes from 195 tests to 118. Checks that need no browser
(thresholds, labels, edit prefill, the downtime form) move to jest, and
34 spec files merge into 16.
- jest gains 148 tests: 15 new test files, 20 extended. Integration
gains 2: `listErrors` grouping exception events, and logout revocation.

#### Issues closed by this PR

Closes #9135
Closes #3849
Closes #9226

#### Additional Information

- Easiest to review commit by commit, and each commit typechecks on its
own. One commit per concern: config, agent rules and docs, drivers
(about 17,800 lines), specs, testids, jest, then the fixes with their
integration tests.
- Product bugs found and left for separate fixes. The specs allow for
each one, and the allowance should go when the fix lands:
- `DeleteToken` drops the cache entry before the row, so a concurrent
lookup can re-cache a revoked token for up to 30 days.
- `/api/v4/metric/metric_metadata` returns 500 instead of 404 for a
metric that does not exist, so the Apdex panel errors on any setup
without span metrics.
- The LLM observability overview calls the public-dashboard and authz
APIs with its synthetic dashboard id, and both return 400.
2026-11-03 11:01:17 +00:00
Tushar Vats
ed50e4aed1 fix(querier): stop zero-start OOM and reject malformed time windows (#13246)
#### Description

- `makeBuckets` looped forever (and OOM-killed the query service) when
`start` was 0: `curEnd - bucket` wrapped below zero and never reached
`start`. Each bucket now starts at `start` once the remaining range
fits, so the loop always ends. A reversed window (DESC cursor reaching
the start boundary) yields no buckets instead of wrapping.
- `start`/`end` are coerced to epoch ms at decode, so validation
compares like units; `QueryRange` still coerces for in-process callers
(domain list/info).
- Validation rejects a missing/zero `start`, `start >= end`, and an
`end` more than 24h in the future (e.g. a 30-day duration read as a
seconds epoch → year 2052). These are well-formedness checks:
unconditional, no config, no opt-out.
- Integration tests freeze the rejections and the step clamp for a
2-year span at a 1s step (logs/traces 42045s, 1502 buckets; metrics
210600s).

#### Issues closed by this PR

Fixes https://github.com/SigNoz/events-pod/issues/178

#### Additional Information

- Behavior change for API clients: `/api/v5/query_range` now returns 400
for a zero `start` or an `end` more than 24h ahead.
- Addresses the validation half of
https://github.com/SigNoz/events-pod/issues/207. Capping how wide a
window may be is deployment capacity rather than well-formedness, so it
is split into SigNoz/signoz#13324, which closes that issue.
2026-11-03 10:58:48 +00:00
Nikhil Mantri
a2d281881c feat(authz): rule view FGA (#13251)
#### Description

- Move `/api/v2/rule_views` routes from role gates to `CheckResources`
on `rule:list`, dashboard views parity.
- Migration 143 backfills rule tuples for managed roles, the first PR in
the stack to check a rule scope.
- Integration tests cover a viewer on all four routes and a custom role
without `rule:list`.

#### Issues closed by this PR

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

#### Additional Information

Third of five stacked PRs splitting #12927. Stacked on #13250.
2026-11-03 10:20:36 +00:00
Naman Verma
85ef5e8e3f feat: add support for distribution query in query range 2026-11-03 15:15:30 +05:30
ashu8912
74d35ebafa fix(channels): reset previous type's template values on type switch (#13374)
#### Description

- Switching channel type now clears the previous type's prefilled
templates that the new type doesn't set, so e.g. Slack → Telegram no
longer sends Slack's title, and PagerDuty → Opsgenie no longer sends a
hidden `details` map. `sendResolved` is kept.
- PagerDuty no longer prefills Client URL with a dead placeholder host;
left empty, the backend links to the instance. Help text says so.
- No page story covers the channel form; the change affects form values,
not layout.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#669
2026-11-03 09:44:32 +00:00
Nikhil Soni
12108b410a fix(traces-qb): synthesized attribute keys follow the JSON column evolution (#13307)
#### Description

- A span attribute metadata does not report fell back to the legacy maps
in every window. It now follows the JSON rollout like a reported key.
- The traces storage reads the column-wide evolution rows in `Fallback`,
where those keys are synthesized, so callers and requested keys stay
untouched.
- The rows come from a separate `SchemaMetadataStore`, built once next
to the metadata store and shared by every traces storage. Found rows are
cached; an empty result is not, since the migrator can add the row while
the service runs.

#### Additional Information

- Alternative to #13168, which carried the rows on the requested key
through every caller.
- The storage gets only `SchemaMetadataStore`; `MetadataStore` is
unchanged.
- New `querier.schemametadatastore.cache_ttl` (default `0`, cached for
the life of the process). The integration server sets `1ns` since its
tests reseed the rollout row between cases.

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

https://claude.ai/code/session_01SXgj9vFWJku1UdD6MDZTmb
2026-11-03 09:36:40 +00:00
Naman Verma
9d91040a55 test: fix apt-get not found error in integration tests (#13367)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- `ubuntu-latest` now resolves to Ubuntu 26.04, which no longer ships
`apt-key`, so the webdriver step failed before any test ran on the
basepath and callbackauthn suites.
- The Chrome signing key is dearmored into `/usr/share/keyrings` and the
apt source references it with `signed-by=`, which works on both 24.04
and 26.04 images.


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

Closes https://github.com/SigNoz/pulse-pod/issues/672
2026-11-03 09:35:11 +00:00
Abhi kumar
65084ba064 fix(dashboards): label unstacked bar layout as overlap (#13369)
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

- Bar chart "Series layout" offered "Side by side", but unstacked bars
render overlapping at the same x. Renamed to "Overlap" with a matching
tile drawing and help text, consistent with the area chart's option.

Before
<img width="388" height="157" alt="image"
src="https://github.com/user-attachments/assets/0b9ecab4-ece8-472c-b924-66c053d2cdc9"
/>


After
<img width="420" height="184" alt="image"
src="https://github.com/user-attachments/assets/680c7bdc-0c49-4111-81f5-e0c633df6715"
/>


#### Issues closed by this PR

Closes SigNoz/pulse-pod#673
2026-11-03 06:09:19 +00:00
Naman Verma
026369948b perf: divide cache buckets for a query into cells in a grid (#13333)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description


https://app.notion.com/p/signoz/Dividing-Cache-Buckets-into-Cells-3fcfcc6bcd19819db159d2f7322c2dd6?source=copy_link

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

Closes https://github.com/SigNoz/pulse-pod/issues/527
2026-11-03 05:31:12 +00:00
Abhi kumar
b8fb5695ea feat(dashboards): add plugin schema for scatter plot panel (#13030)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds the `signoz/ScatterPlotPanel` kind, which draws one dot per group
placed by two aggregations, per the Scatter Plot PRD.
- `dimensions.x/y/size` reference value columns by the Table column key
(`A` or `A.<expression>`); `dimensions.color` is a group-by label. All
four are optional and the renderer picks defaults when they're empty.
- `formatting` reuses `TableFormatting`, so `columnUnits` carries over
when switching between Table and Scatter Plot.
- Axes are per-axis with a new `scale` enum (`auto` | `linear` | `log` |
`symlog`, default `auto`); the shared `Axes` type is unchanged.
- Allows every query kind, PromQL included. Public dashboards turn on
`formatTableResultForUI` for this kind so rows from all queries are
joined by group, as for Table.

#### Additional Information

- The generated TS schemas aren't in this PR: adding the kind breaks the
exhaustive panel-kind maps in the V2 dashboard code. The frontend PR
will regenerate them and register the panel.

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

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 20:46:04 +00:00
Vikrant Gupta
ff6e0a26fc feat(roles): pick the grant key in the telemetry selector wizard (#13226)
#### Description

- The role wizard's Key field becomes a dropdown of the supported
telemetry grant keys, `signoz.workspace.key.id`, `resource.service.name`
and `resource.deployment.environment.name`, instead of a fixed value. A
typed selector selects its key in the dropdown, and
`resource.signoz.workspace.key.id` folds to the bare workspace key as
the backend does.
- Covered by the Role Editor Storybook story (`Telemetry Key Dropdown`,
`Telemetry Key Scoped`).

#### Issues closed by this PR

Contributes to SigNoz/keystone-pod#73
2026-11-02 18:39:53 +00:00
Nikhil Mantri
a02ea41446 feat(authz): downtime schedule FGA (#13250)
#### Description

- Move `/api/v1/downtime_schedules` routes from role gates to
`CheckResources` with resource defs.
- Create and update check `rule:attach` on every id in `alertIds`.
Update diffs against the stored schedule: attach for added, detach for
removed.
- Migration 142 backfills planned-maintenance tuples plus attach and
detach on planned-maintenance and rule for admin and editor.
- Integration tests cover viewer, editor, admin, and a custom role
without rule attach or detach.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#289
Part of SigNoz/pulse-pod#355

#### Additional Information

Second of five stacked PRs splitting #12927. Stacked on #13249.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 18:29:40 +00:00
Aditya Singh
c63f818789 feat(traces-explorer): fetch all span fields in the list view (#13275)
#### Description

- The traces explorer list view no longer sends the selected columns as
`selectFields`. The API now returns every span field for each row. The
span details drawer in the next PR uses this data, so it can open
without another request.
- Adding, removing or reordering a column no longer refetches the list.
- The table reads attribute and resource columns from the `attributes`
and `resource` maps on each row. It uses `getSpanAttribute`, the same
lookup as trace details.
- `getSpanAttribute` now keeps `false` values. Before, boolean `false`
attributes showed "-" in the table and dropped out of the trace details
hover previews. Color-by now treats `0` and `false` as real values, not
`unknown`.

#### Issues closed by this PR

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


#### Screenshots/Screen recording

No refetch on add/remove columns


https://github.com/user-attachments/assets/799b39b3-3594-441e-b5dc-0c8177c29afb

All fields fetched

<img width="1687" height="1000" alt="all fields fetched"
src="https://github.com/user-attachments/assets/af8dd374-6b30-40a5-92dc-caa07defc463"
/>



#### Additional Information

- Two backend gaps show "-" in some columns:
- `scope.name` and `scope.version`: the all-fields mode does not select
the `scope` column. Value suggestions for these fields are also empty.
The fix is to add `scope` to `ContextualSpanColumns`.
- Old camelCase column names (`durationNano`, `httpMethod`, ...) in
saved views and column preferences: the backend resolved them only when
they were in `selectFields`. The fix is a backend migration that renames
them. The workaround is to remove the column and add it again.
- Each row is larger, because the response carries all span fields.
- Export and add to dashboard still use the selected columns.
- The List view story rows now use the nested `resource` shape. The page
renders the same, so the existing story covers it.
- cc. @tewarig
2026-11-02 17:56:08 +00:00
Vikrant Gupta
6026e2372d feat(authz): support service and environment keys in telemetry selectors (#13036)
#### Description

- Telemetry grants can now scope on `resource.service.name` and
`resource.deployment.environment.name`, alongside
`signoz.workspace.key.id`.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#73
2026-11-02 16:28:30 +00:00
Nikhil Soni
be83a3e464 feat(traces): return instrumentation scope in waterfall, flamegraph and list view (#13280)
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

- Waterfall spans and the empty-`selectFields` list view now return
`scope: {name, version, attributes}`, read from the `scope` JSON column.
- Flamegraph `selectFields` accept `fieldContext: scope`: `scope.name` /
`scope.version` resolve to the declared paths, any other name to a scope
attribute, the same way the query builder resolves them.
- Scope attributes are flat dotted keys, like span attributes.
- Span details drawer shows `scope`.

#### Issues closed by this PR

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

For Frontend changes https://github.com/SigNoz/events-pod/issues/230

#### Additional Information

- Stacked on #13274.
- API contract change: OpenAPI spec and frontend client regenerated in
their own commits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:32:58 +00:00
Nikhil Soni
34b6cbb98e fix(saved-views): rename old camelCase trace select fields (#13326)
#### Description

- Support for camelCase trace field names (`durationNano`,
`serviceName`, `httpMethod`, …) in select fields is being removed.
- Adds migration 141, which renames them in traces saved views'
`spec.selectedFields` using the same mapping as `oldToNew`.
- Drops `fieldContext` on renamed entries: legacy columns carry
`attribute` from migration 114, which would make `service.name` resolve
to a span attribute. Without it the query builder resolves the field
itself.
- If the new name is already selected, the renamed entry is dropped.
Unparseable rows are logged and skipped.
- Prod backups: 52 traces views across 29 deployments still use these
names, mostly the old default columns. Tested on a restored backup by
running migrations 049–141 through the server.

#### Additional Information

- Logs views and old names in filters / orderBy / groupBy are not
touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-02 10:24:17 +00:00
721 changed files with 42631 additions and 8971 deletions

View File

@@ -6,12 +6,12 @@ model: sonnet
color: blue
---
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md). **Read that doc first.** Adhere to it.
You are the Playwright Test Generator for the SigNoz frontend. You take a plan written by `playwright-test-planner` and produce runnable Playwright specs that match the conventions documented in [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md). **Read both first.** Adhere to them.
# Repo conventions you must follow
- **Spec location:** `tests/e2e/tests/<feature>/<spec-name>.spec.ts`. One file per resource; cross-resource concerns get their own file. Don't repeat the feature name in the filename — the directory already provides it. `dashboards/list/search.spec.ts`, not `dashboards/list/dashboards-list-search.spec.ts`.
- **Drivers first.** New specs import `test`, `expect` and constants from one driver module in [tests/e2e/drivers/](../../tests/e2e/drivers/) and receive the feature's driver as a fixture. A spec never passes `page` around.
- **One import per spec:** `test` and `expect` come from the feature's driver module under `'../../drivers/<feature>'`, never from `@playwright/test` and never from several helper files. Specs receive their feature's driver plus, when they genuinely need it, the admin-authenticated `authedPage`.
```ts
import { expect, PATH, test } from '../../drivers/routing';
@@ -22,42 +22,24 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
```
- A driver is a function closing over the page that returns `Locator` getters and async actions. Actions wait inside themselves (e.g. `run()` pairs the click with `waitForResponse(/query_range/)`), so specs don't.
- `driver(name, build, dispose?)` in `drivers/kit.ts` makes it a test-scoped fixture on `authedPage`; `dispose` deletes what it seeded. `seeded(name, open, close)` makes a worker-scoped seed.
- Layers compose: `withNewPanel(withEditor(panelsTest))`. Extend the closest existing driver instead of starting a parallel one.
- When no driver covers the feature, add `drivers/<feature>.ts` with the spec. Locators live in the driver, not the spec. `drivers/` is AI-owned: keep the banner, and change a driver only alongside a spec that needs it.
- Read [tests/e2e/tests/dashboards/editor/query.spec.ts](../../tests/e2e/tests/dashboards/editor/query.spec.ts) and [drivers/panel-editor.ts](../../tests/e2e/drivers/panel-editor.ts) for the canonical shape.
- **Plain auth fixture** only for a spec no driver fits: import `test` and `expect` from `'../../fixtures/auth'` (never `@playwright/test`) and use the `authedPage` fixture (the admin, the only user the bootstrap seeds).
- **Extended fixtures:** older suites import domain fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md).
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
```ts
// Alert list tests - need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// Alert history tests - need evaluated history rows
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
- **Seeds are layers on the feature's `test`.** A spec destructures the seed it needs (`alertList`, `ownedRules`, `resolvedHistory` for alerts) instead of importing a fixtures module. A new seed goes in the feature's driver directory: `seeded()` for read-only data shared by a worker, a test-scoped layer for anything a test mutates.
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing resources. Cleanup shapes, in order of preference:
- **A driver's `dispose`** — the driver tracks what it seeded and deletes it after the test (`dashboards.seed(...)` in `drivers/dashboards.ts`). The spec has no cleanup code.
- **A worker-scoped `seeded(...)` layer** — read-only data shared by a worker's tests.
- **Per-test `try / finally`** or **suite-level `afterAll` with an id registry** — specs without a driver. Suite hooks can't see `authedPage`; use `newAdminContext(browser)` from `helpers/auth.ts`.
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `helpers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts: per-test seeds from parallel workers land in the same tenant. Seed missing shapes through the seeder with unique service names (`helpers/alerts/seeding.ts`).
- **Reuse helpers from `tests/e2e/helpers/`** inside drivers. Don't reinvent:
- [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) — `authToken`, `watchConsole`, `collectRequests`, `requestUrl`, `seederUrl`.
- [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) — `newAdminContext(browser)`.
- [`helpers/query-range-mock.ts`](../../tests/e2e/helpers/query-range-mock.ts) — `query_range` mocks for error / no-data / warning / loading states only; install before `goto`.
- **Seed via API when the UI flow is multi-step or brittle**, through the driver (`dashboards.seed`, `seedAndOpen`, `seedAndEdit`). `page.request.*` does **not** auto-attach `Authorization`; drivers add `authToken(page)`.
- **Static payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (`dashboards/panels.ts`, `traces/large-trace.json`).
- **Resource names:** short and descriptive, no timestamps. Where parallel workers share a list, add a random suffix as `uniqueName` in `drivers/dashboard-list.ts` does.
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. Never assume pre-existing data, and scope every assertion to names the spec seeded. Pick the scope per the Seeding table in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md): test scope (a test-scoped layer, or `try / finally` through the driver) for mutations, worker scope (`seeded()`) for read-only data, suite `beforeAll` for a dataset only one file uses.
- **Drive everything through the feature's driver** in `tests/e2e/drivers/`. A spec that reaches for `page.locator(...)` or `page.getByTestId(...)` is missing a driver method — add the method instead. `onAdminPage(browser, …)` from `drivers/kit.ts` is what suite hooks use, because `authedPage` is test-scoped and not visible to them.
- **Seed through the kit when the UI flow is not under test.** Control-plane objects go through the feature driver (`dashboards.seed`, `alerts.seed.threshold`), which uses `apiDriver(page)` from `drivers/api.ts`; telemetry goes to the seeder. `page.request.*` does **not** carry the token, so never seed with it.
- **Golden telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads logs, traces and metrics over the last 6h (constants in `GOLDEN`, `drivers/dashboard-spec.ts`). Query within ≤ 6h and assert structure (series, rows, request payload), never exact counts.
- **`query_range` mocks** in [`drivers/query-range-mock.ts`](../../tests/e2e/drivers/query-range-mock.ts) are for error / no-data / warning / loading states only; install before `goto`.
- **Reusable payloads live in [tests/e2e/testdata/](../../tests/e2e/testdata/)** (traces and logs POSTed to the seeder, `dashboards/panels.ts`). Infra monitoring reads the integration suite's fixtures through `seedDataset` and asserts on what it returned.
- **Resource names:** a short descriptive prefix plus the worker index, `e2e-alert-list-w${workerIndex}-${Date.now()}`. Six workers share one stack, so a name without the stamp collides.
- **Serial mode** when tests in a file mutate the same list page:
```ts
test.describe.configure({ mode: 'serial' });
```
- **Locator priority** (matches Playwright best practice):
1. `data-testid` (preferred — these are stable, app-author-provided handles)
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`, `getByPlaceholder(...)`, `getByText(...)`
4. CSS / `locator('.ant-…')` — last resort
- **Locate by testid, in the driver.** A missing testid is added to the product source. Role, label and CSS lookups are a stopgap; antd internals (`.ant-select-dropdown`, in `antd.ts`) are the exception.
- **A driver method is navigation or a locator, never a scenario.** A method that exists for one spec belongs in that spec.
- **No `test.slow()` in drivers.** It compounds when called in a loop; use `allowFor(floor)` from `drivers/kit.ts`.
- **Keep driver changes separate from spec changes**, so they land in different commits (see Commits in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md)).
- **Never commit `test.only`.** CI runs with `forbidOnly: true`.
- **No `page.waitForTimeout(ms)`** — always prefer `await expect(locator).toBe…()`.
@@ -78,8 +60,8 @@ For each scenario in the plan:
- **`describe` block** matches the top-level plan section.
- **Title** matches `TC-NN <description>` exactly.
- **Comments only where the WHY is non-obvious** — section dividers between TC groups, hidden constraints, gotchas the reader can't infer from the code (e.g. "Monaco swallows Escape — click the title to blur first"). **Do not narrate steps** by pasting the plan's bullets back as `// 1. Navigate…` `// 2. Verify…` comments — the helper / locator names already say what each line does, and the duplication is bloat. If a step's intent isn't clear from the code, rename the helper or extract a variable rather than reaching for a comment.
- **Imports** from the feature's driver module (or `../../fixtures/auth` when none fits). **Do not** import from `@playwright/test` directly.
- **Cleanup** through the driver's `dispose`; `try / finally` only without a driver.
- **Imports** from `../../drivers/<feature>`. **Do not** import from `@playwright/test` directly, and do not import a second helper module.
- **Cleanup** comes from the seed layer or the driver's `dispose`. Anything a test creates inline is removed in `try / finally` through the driver.
- **Move recorded locators and waits into the driver.** The log gives raw `page.*` calls; the spec should read as driver actions.
# Quality bar — what to write, what to skip
@@ -140,7 +122,7 @@ test.describe('Dashboards — list search', () => {
});
```
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. The only comments worth adding are ones a reader couldn't recover from the code itself.
Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locators (they live in `drivers/dashboard-list.ts`), and no `// 1. …` step narration. If a step needs an element the driver does not expose, add a method to the driver; do not reach for `page.getByTestId(...)` in the spec. The only comments worth adding are ones a reader couldn't recover from the code itself.
# Known UI gotchas (apply when relevant)

View File

@@ -6,7 +6,7 @@ model: sonnet
color: red
---
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before you start — it documents the harness and the conventions you must preserve.
You are the Playwright Test Healer for the SigNoz E2E suite. You debug and fix red specs with a methodical approach. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) before you start — they document the harness and the conventions you must preserve.
# Preconditions
@@ -27,11 +27,11 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
- `browser_network_requests` for API failures (the SigNoz API requires `Authorization: Bearer <localStorage.AUTH_TOKEN>`; 401s usually mean the test bypassed the fixture).
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
4. **Root-cause.** Distinguish between:
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
- **Selector drift** — the app changed `data-testid` or text. Fix the locator. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action.
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Ensure the test seeds through its seed layer or driver (a driver's `dispose` cleans up), scopes its assertions to the names it seeded (with the worker index), and cleans up. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug. Assert structure instead.
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
5. **Fix.** Edit the driver first, the spec second. A driver change must keep every other spec importing it green, so run them too. Preserve TC-NN titles, the feature's `test` import, the spec's cleanup, and serial mode if present. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
6. **Re-run only the fixed test** before moving to the next failure. Three options:
- `npx playwright test -g 'TC-09' --project=chromium` — target a single TC by title
- `npx playwright test --last-failed --project=chromium` — replay everything that failed last run
@@ -41,17 +41,20 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
# Repo-specific signals
- **Reuse drivers and helpers before adding new code.** [`tests/e2e/drivers/`](../../tests/e2e/drivers/) holds the per-feature seed, cleanup, navigation and action methods (`kit.ts` explains `driver` / `seeded`); [`helpers/common.ts`](../../tests/e2e/helpers/common.ts) and [`helpers/auth.ts`](../../tests/e2e/helpers/auth.ts) the plain plumbing. Prefer them over re-inlining auth/login/POST code in the spec.
- **Fix it in the driver, not in the spec.** Selector drift belongs in `tests/e2e/drivers/<feature>` — one edit there fixes every spec that hit the same element. A `page.locator(...)` added to a spec body is a regression, not a fix.
- **Ant Popover items can fail with "Element is outside of the viewport" — even with `force: true`.** `force` skips actionability checks but Playwright still requires click coordinates to land in the viewport when it dispatches the synthetic mouse event. The robust fix is `tooltip.getByText('…').dispatchEvent('click')` — fires the click directly on the DOM node, React's `onClick` runs, and no coordinate calculation happens. Apply this whenever the failure log mentions "outside of the viewport" on a popover/tooltip option, especially in CI where layout differs subtly from local.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. Add `scrollIntoViewIfNeeded` before the click, in the driver action.
- **Action-icon rows below the fold.** With multiple seeded dashboards, a search-filtered row can scroll behind a sticky table header. Add `scrollIntoViewIfNeeded` before the click, in the driver action (`dashboardList.openRowMenu(name)`).
- **React Query mutations vs page.goto.** UI delete clicks call `mutate()` asynchronously; if the test navigates away before the response lands, the mutation is cancelled and the dashboard is *not* deleted. Wait for the DELETE response and the dialog dismissal explicitly: `page.waitForResponse((r) => r.request().method() === 'DELETE' && /\/dashboards\//.test(r.url()))` plus `await expect(dialog).not.toBeVisible()`.
- **Monaco editor swallows Escape inside the Import JSON dialog.** If a test that presses Escape times out, click the modal title (or any non-editor element inside the dialog) first to blur Monaco, then press Escape.
- **The list pages render zero-state when the workspace is empty.** List controls and the `new-dashboard-cta` testid are absent in zero-state. A timeout on those usually means the workspace was empty — seed first via `dashboards.seed(...)`.
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/common.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `dashboards.seed(...)`.
- **Auth.** `tests/e2e/drivers/auth.ts` logs in once per worker and caches `storageState` (localStorage with `AUTH_TOKEN`). For API-driven seeding and cleanup use `apiDriver(page)` from `drivers/api.ts`, which attaches the header for you. Never re-implement login.
- **Seeds.** Feature seeds are layers on the feature's `test`, e.g. `alertList`, `ownedRules` and `resolvedHistory` in `drivers/alerts/seeds.ts`. When a test fails on missing data, check which seed it destructures and whether it scopes to that seed's names.
- **Timeouts.** Never add `test.slow()` to a driver: it triples the remaining budget per call and compounds in loops. Use `allowFor(floor)` from `drivers/kit.ts`. Budgets and their measurements are in `tests/e2e/docs/flake-notes.md`.
- **CI-only failures.** Reproduce with `STRESS=1 CI=1 pnpm test <path>` from `tests/e2e/` before calling it a product bug.
- **Commits.** Driver fixes and spec fixes go in separate commits (see Commits in `tests/e2e/AGENTS.md`).
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
- **Type-check.** After edits, run `pnpm typecheck` and `pnpm lint` from `tests/e2e/`.
# Hard rules

View File

@@ -6,7 +6,7 @@ model: sonnet
color: green
---
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) before planning — it documents the harness, the `authedPage` fixture, the TC-NN naming convention, and the self-contained-state principle that every plan you write must respect.
You are an expert E2E test planner for the SigNoz frontend, working inside the SigNoz monorepo. Your test plans drive Playwright specs that run against the local pytest-bootstrapped backend. Read [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) and [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) before planning — they document the harness, the driver kit, the TC-NN naming convention, the four-layer rule, and the self-contained-state principle that every plan you write must respect.
You will:
@@ -16,28 +16,62 @@ You will:
- Interactive elements and their `data-testid` attributes (preferred locators)
- Conditional rendering (empty states, loading, error, role-gated UI)
- URL query-param state (search, sort, pagination)
- API endpoints the UI calls — these inform what cleanup endpoints exist for `try/finally` teardown
- The frontend stores its JWT in `localStorage` under `AUTH_TOKEN` and the API requires `Authorization: Bearer <token>` for protected endpoints. Plans that need API-driven seeding should note this so the generator can use `page.request.*`.
- API endpoints the UI calls — these tell the generator which seed and cleanup calls the driver needs
- Seeding goes through the feature driver (`apiDriver` underneath) for control-plane objects and through the seeder for telemetry, never through `page.request.*`. Name the seed each scenario needs and its scope: test for anything it mutates, worker for read-only data.
2. **Check what's already wired up.**
- [tests/e2e/drivers/](../../tests/e2e/drivers/) holds one driver per feature (e.g. `dashboards.ts`, `dashboard-list.ts`, `panel-editor.ts`, `routing.ts`, `channels.ts`). A driver is a fixture exposing the feature's locators and actions, including API seeding that cleans itself up (`dashboards.seed(...)`). When one covers the feature, write steps in terms of its actions by name so the generator reuses them. When none does, say so: the generator adds one.
- [tests/e2e/helpers/](../../tests/e2e/helpers/) holds plain helpers: `common.ts` (`authToken`, `watchConsole`, `collectRequests`, `seederUrl`), `auth.ts` (`newAdminContext`), `query-range-mock.ts` (mock `query_range` for error, no-data, warning and loading states only).
- **Telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads a golden OTel-demo dataset (logs, traces, metrics over the last 6h; constants in `GOLDEN` in `helpers/dashboard-spec.ts`). Plans that query data use a window ≤ 6h and assert structure, not exact counts. Data the golden set lacks is seeded per test through the seeder (`helpers/alerts/seeding.ts` shows how).
- [tests/e2e/testdata/](../../tests/e2e/testdata/) holds static payloads (`dashboards/panels.ts`, `traces/large-trace.json`) — note in the plan if a scenario benefits from one.
2. **Work out which layer each scenario belongs to — before planning it.**
3. **Navigate and explore**
This is the step that keeps the suite small, and it has to happen here
because by review time the spec already exists. Read
[tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md) for the full rule. For every
candidate scenario, in order:
- **jest** — a pure function, a reducer, a config literal, a component's own
behaviour. Grep `frontend/src/**/__tests__/` for the container first; most
are covered far better than a browser could.
- **Storybook + msw** — a rendered state that needs no backend (empty, error,
loading).
- **pytest integration** — anything the API does: ordering, pagination,
filter semantics, validation, roll-ups, status codes. **Grep
`tests/integration/` and name the test you found.** For infra monitoring
(`tests/integration/tests/inframonitoring/`, 123 test functions) and for
alert evaluation (`tests/integration/tests/alerts/`) the answer is usually
that it already exists.
- **E2E** — only when a real response has to reach a real DOM: URL and
history, a write path that must persist, cross-page navigation, one seeded
proof per surface.
The plan must say, per scenario, why it is not one of the cheaper three. A
scenario that cannot answer that does not go in the plan.
3. **Check what's already wired up.**
- [tests/e2e/drivers/](../../tests/e2e/drivers/) holds one driver module per
feature plus the shared ones (`tanstack`, `querysearch`, `antd`, `api`,
`storage`, `console`). Reference driver methods by name in the steps so the
generator reuses them rather than reinventing a locator.
- If a step needs something the driver does not expose, say so — the
generator adds a driver method, not a `page.locator` in the spec.
- **Telemetry is pre-seeded.** `bootstrap/global.setup.ts` loads a golden
OTel-demo dataset (logs, traces, metrics over the last 6h; constants in
`GOLDEN` in `drivers/dashboard-spec.ts`). Plans that query data use a
window ≤ 6h and assert structure, not exact counts.
- [tests/e2e/testdata/](../../tests/e2e/testdata/) holds static payloads
(`dashboards/panels.ts`, `traces/large-trace.json`); note in the plan if a
scenario benefits from one.
4. **Navigate and explore**
- Invoke `planner_setup_page` once before any other browser tool.
- Use `browser_snapshot` to read the page's accessibility tree. **Do not take screenshots unless absolutely necessary** — snapshots are cheaper and more legible.
- Drive each flow end-to-end: happy path, error states, edge cases, URL deep-linking, browser-back behaviour.
4. **Design comprehensive scenarios**
5. **Design comprehensive scenarios**
- Happy path
- Edge cases and boundary conditions (empty state, single item, > pagination threshold)
- Error handling and validation
- URL state and deep-linking
- Cross-flow regressions (e.g. searching while paginated)
5. **Structure the test plan**
6. **Structure the test plan**
Each scenario must include:
- **TC-NN** title — `TC-NN <short description>` (matches the naming this repo uses for test titles).
@@ -46,7 +80,7 @@ You will:
- Expected outcomes per step.
- Cleanup notes (what gets created and how to remove it — usually via API).
6. **Save the plan and the checklist**
7. **Save the plan and the checklist**
- **Plan:** `tests/e2e/specs/<feature>/<feature>-test-plan.md`. **`tests/e2e/specs/` is gitignored** — plans are scratch artifacts: working input for the generator, regenerable, not committed. Don't treat them as durable documentation. The committed tests are the source of truth.
- **Checklist:** `tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`. A manual-verification runbook that mirrors the TC list one-to-one (one checkbox per TC) for QA hand-off. Same gitignore, same scratch status.
- **The checklist must stay in sync with the TCs.** When you regenerate the plan, regenerate the checklist alongside it — they share TC IDs and titles, and the checklist ordering must match. If the existing spec under `tests/e2e/tests/<feature>/` has more / fewer / different TCs than the prior plan, the spec is authoritative: re-derive plan and checklist from it.
@@ -97,8 +131,8 @@ The dashboards list page (`/dashboard`) lists all dashboards in the workspace. F
**Quality bar:**
- Steps must be specific enough that any tester (or the generator agent) can follow without ambiguity.
- Include negative scenarios — empty state, no-match search, validation errors.
- Each scenario must own its preconditions and cleanup. **Do not invent cross-file global fixtures** — they break parallel-by-file execution. Prefer a driver's self-cleaning seed (`dispose`) or worker-scoped `seeded(...)` layer; without a driver, suite-level `beforeAll` / `afterAll` within one spec file or per-test `try / finally` both work.
- Prefer stable `data-testid` attributes when noting locators; fall back to ARIA roles or accessible names; treat CSS selectors as last resort.
- Each scenario must own its preconditions and cleanup, and assert only on names it seeded. **Never depend on data another spec file created.** Files run in parallel. Pick the seed scope from the Seeding table in [tests/e2e/AGENTS.md](../../tests/e2e/AGENTS.md); a driver's self-cleaning seed (`dispose`) or a worker-scoped `seeded(...)` layer covers most cases.
- Note the `data-testid` for every element a step touches. Where none exists, say so: the generator adds it to the product rather than falling back to roles or CSS (antd internals aside).
- **Don't pad coverage.** Every TC must catch a regression that would actually ship if the test were missing — a real branch in the source, a real user-visible failure mode. A TC that asserts a hard-coded string is still rendered, or that a button is still a button, adds nothing but maintenance cost: it inflates the suite, slows CI, and trains readers to skim past the directory. Before writing a scenario, ask "what code change would break this?" — if the only answer is "deleting the literal under test," cut it or fold it into a richer scenario as one assertion among many.
- **Collapse near-duplicates.** Two TCs that differ only in input value (search by title vs search by description, when the underlying code path is the same) should merge into one parameterised scenario unless each input genuinely exercises a distinct branch. Prefer one assertion-rich TC over three thin ones.
- **Smoke-checks aren't TCs.** "Heading is visible" belongs as the first assertion inside a real scenario, not as its own numbered case.

6
.gitattributes vendored
View File

@@ -2,3 +2,9 @@
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true
# Transcribed E2E data: seeded telemetry, golden payloads and the entity
# datasets. Regenerated from the seeder rather than hand-edited, and re-derived
# by the specs that read them, so they are collapsed in review.
tests/e2e/testdata/** linguist-generated=true
tests/e2e/**/__generated__/** linguist-generated=true

View File

@@ -106,8 +106,8 @@ jobs:
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo "deb [signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qqy
sudo apt-get -qqy install google-chrome-stable
CHROME_VERSION=$(google-chrome-stable --version)

View File

@@ -16,6 +16,14 @@ packages:
filename: "maintenance.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/types/telemetrytypes:
interfaces:
SchemaMetadataStore:
config:
dir: '{{.InterfaceDir}}/telemetrytypestest'
filename: "schema_metadata_store.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/tokenizer:
config:
all: true

View File

@@ -178,6 +178,9 @@ querier:
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
schemametadatastore:
# How long found schema metadata stays cached; 0 caches it for the life of the process.
cache_ttl: 0s
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -4224,6 +4224,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -5250,6 +5257,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -5265,6 +5273,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5278,6 +5287,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5384,6 +5394,21 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/ScatterPlotPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -5494,6 +5519,16 @@ components:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the slices are computed over.
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -5748,6 +5783,161 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.x is plotted along.
"y":
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
description: The axis dimensions.y is plotted along.
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none; the tooltip still names the column.
type: string
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesAxisScale'
description: auto (default) uses log, or symlog when values include zero
or negatives, when that spreads the dots clearly more than linear does;
otherwise linear. log cannot place a value at or below zero and leaves
that dot out; symlog is log-like but places it.
softMax:
description: The highest value the axis always shows; data above it still
extends the axis, as does a threshold on it. Null fits the data.
nullable: true
type: number
softMin:
description: The lowest value the axis always shows; data below it still
extends the axis, as does a threshold on it. Null fits the data. A log
axis ignores a bound at or below zero.
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
description: Dot size and opacity.
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values color the dots and drive the legend. Empty colors by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales each dot's area between chartAppearance.points.minSize
and maxSize. Empty draws every dot at chartAppearance.points.size, as
does a dot with no value.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty, or a key the result
lacks, uses the first value column.
type: string
"y":
description: Value column key plotted on the y axis. Empty, or a key the
result lacks, uses the first value column x does not use.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
description: Title, bounds and scale of each axis.
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
description: Dot drawing options.
dimensions:
allOf:
- $ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
description: 'Which result columns place, size and color the dots. Each
dot is one row of the joined scalar result: one group.'
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
description: Per-column units and decimal precision. The x and y columns'
units label their axes; every bound column's unit shows in the tooltip.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement and series colors. A series is one color group,
labeled by its colorBy values joined with a comma, or All when nothing
is grouped.
thresholds:
description: Reference lines drawn at fixed values across the x or y axis.
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the groups are computed over.
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the largest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 24.
minSize:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of the smallest dot when dimensions.sizeBy is set,
in CSS pixels from 2 to 40. Null means 4.
opacity:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointOpacity'
description: Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
size:
allOf:
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
description: Diameter of every dot when dimensions.sizeBy is unset, in CSS
pixels from 2 to 40. Null means 6.
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
allOf:
- $ref: '#/components/schemas/DashboardtypesThresholdAxis'
description: 'The axis the line marks: y (default) draws it horizontally,
x vertically. Unit converts against that axis''s column unit, and the
axis extends to keep the line in view.'
color:
description: 'The CSS color of the line, for example #F1575F, which matches
the SigNoz red preset; CSS named colors such as red also work.'
type: string
label:
description: The caption shown on the line.
type: string
unit:
description: The unit id of value, such as ms or percent. Empty means the
panel's unit, or the column's unit for a table threshold. Prefer the panel's
unit; a different unit works only when it converts to it, such as s against
a panel in ms.
type: string
value:
description: The value the line is drawn at, in unit.
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5914,6 +6104,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -9370,9 +9565,9 @@ components:
Querybuildertypesv5AggregationMeta:
properties:
buckets:
description: The ascending upper bounds of the value bands, set only for
heatmap results; each point then carries one count per bound plus a trailing
overflow count in values.
description: The ascending upper bounds of the value buckets, set for heatmap
and distribution results; each point then carries one count per bound
plus a trailing overflow count in values.
items:
format: double
type: number
@@ -9527,6 +9722,56 @@ components:
nullable: true
type: array
type: object
Querybuildertypesv5Distribution:
properties:
counts:
description: One count per bound in meta.buckets plus a trailing overflow
count, summed over every step of the requested time range.
items:
format: double
type: number
nullable: true
type: array
labels:
description: The group-by field values that identify this distribution.
items:
$ref: '#/components/schemas/Querybuildertypesv5Label'
type: array
type: object
Querybuildertypesv5DistributionAggregation:
properties:
alias:
description: The alias of the aggregation, or the internal name __result_N,
with N its index, when no alias was given.
type: string
distributions:
description: One distribution per group-by combination.
items:
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
nullable: true
type: array
index:
description: The zero-based position of the aggregation in the query.
type: integer
meta:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
description: Display metadata for the aggregation, including the bucket
upper bounds the counts are positional against.
type: object
Querybuildertypesv5DistributionData:
properties:
aggregations:
description: One entry per aggregation of the query. The order is not guaranteed;
match entries to aggregations by index.
items:
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
nullable: true
type: array
queryName:
description: The name of the query that produced this result.
type: string
type: object
Querybuildertypesv5ExecStats:
description: 'Execution cost of the request: rows and bytes scanned and execution
time. A measure of cost, not of whether data exists.'
@@ -10350,10 +10595,12 @@ components:
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
properties:
results:
description: 'One result per enabled query, typed by the request type: TimeSeriesData
for time_series, ScalarData for scalar and RawData for raw requests.'
for time_series and heatmap, ScalarData for scalar, RawData for raw and
DistributionData for distribution requests.'
items: {}
nullable: true
type: array
@@ -10543,8 +10790,9 @@ components:
type: object
Querybuildertypesv5QueryRangeResponse:
description: 'Response from the v5 query range endpoint. The data.results array
contains typed results depending on the requestType: TimeSeriesData for time_series,
ScalarData for scalar, or RawData for raw requests.'
contains typed results depending on the requestType: TimeSeriesData for time_series
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
distribution requests.'
properties:
data:
allOf:
@@ -10641,6 +10889,7 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -10752,8 +11001,8 @@ components:
format: double
type: number
values:
description: The per-band counts for heatmap results, in the order of meta.buckets
plus a trailing overflow count.
description: The per-bucket counts for heatmap results, in the order of
meta.buckets plus a trailing overflow count.
items:
format: double
type: number
@@ -12131,6 +12380,8 @@ components:
additionalProperties:
type: string
type: object
scope:
$ref: '#/components/schemas/SpantypesScope'
spanId:
type: string
timestamp:
@@ -12147,6 +12398,7 @@ components:
- event
- attributes
- resource
- scope
type: object
SpantypesGettableFlamegraphTrace:
properties:
@@ -12387,6 +12639,16 @@ components:
nullable: true
type: array
type: object
SpantypesScope:
properties:
attributes:
additionalProperties: {}
type: object
name:
type: string
version:
type: string
type: object
SpantypesSpanAggregation:
properties:
aggregation:
@@ -12756,6 +13018,8 @@ components:
type: object
response_status_code:
type: string
scope:
$ref: '#/components/schemas/SpantypesScope'
span_id:
type: string
status_code:
@@ -15064,9 +15328,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:list
- tokenizer:
- VIEWER
- planned-maintenance:list
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15121,9 +15385,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- tokenizer:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15168,9 +15436,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:delete
- tokenizer:
- EDITOR
- planned-maintenance:delete
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15226,9 +15494,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:read
- tokenizer:
- VIEWER
- planned-maintenance:read
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15283,9 +15551,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- tokenizer:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
summary: Update downtime schedule
tags:
- downtimeschedules
@@ -24931,9 +25207,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List rule saved views
tags:
- rules
@@ -24989,9 +25265,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Create rule saved view
tags:
- rules
@@ -25043,9 +25319,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Delete rule saved view
tags:
- rules
@@ -25112,9 +25388,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Update rule saved view
tags:
- rules

View File

@@ -11,7 +11,7 @@ Before running E2E tests, ensure you have the following installed:
- Python 3.13+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- Docker (for containerized services)
- Node 18+ and Yarn
- Node 18+ and pnpm
### Initial Setup
@@ -24,8 +24,8 @@ uv sync
2. Install Node deps and Playwright browsers:
```bash
cd e2e
yarn install
yarn install:browsers # one-time Playwright browser install
pnpm install
pnpm install:browsers # one-time Playwright browser install
```
### Starting the Test Environment
@@ -84,127 +84,118 @@ tests/
├── playwright.config.ts # loads .env + .env.local via dotenv
├── .env.example # staging-mode template
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── AGENTS.md # read this before writing a spec
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
│ ├── auth.ts
│ └── logs-explorer.ts
├── testdata/ # static data files (JSON) used by helpers and tests
│ └── traces/large-trace.json # (example)
├── tests/ # Playwright .spec.ts files, one dir per feature area
├── drivers/ # the driver kit: one module per feature
│ ├── kit.ts # layer(), seeded(), the root `test`
│ ├── auth.ts # login + per-worker storageState
│ ├── api.ts # authed HTTP client + seeder URL
│ ├── tanstack.ts # the shared table driver
│ ├── alerts/ # feature driver, seeds, payloads
│ └── infra/ # feature driver, seeds, __generated__/datasets.ts
├── docs/flake-notes.md # measurements and timeout budgets
├── testdata/ # static data files (marked generated)
├── tests/ # Playwright .spec.ts files, one dir per feature
│ └── alerts/
│ └── alerts.spec.ts # (example)
│ └── list.spec.ts # (example)
└── artifacts/ # per-run output (gitignored)
├── html/ # HTML reporter output
├── json/ # JSON reporter output
└── results/ # per-test traces / screenshots / videos on failure
```
### `fixtures/` vs `helpers/` — what goes where
### The driver kit
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. `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/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
A spec imports **one** module: its feature's `test` constant. That constant is
built by composing layers, and each layer adds one named driver to the test
arguments — so a spec body never handles a `Page`:
```ts
import { expect, PATH, test } from '../../drivers/routing';
import { expect, test } from '../../drivers/alerts';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
test('TC-01 seeded rules render', async ({ alerts, alertList }) => {
await alerts.list.goto({ search: alertList.namePrefix });
await expect(alerts.list.rows()).toHaveCount(10);
});
```
- `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.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
- **`drivers/kit.ts`** holds the composition primitives. `driver(name, build, dispose?)`
binds a factory to the authenticated page, and `dispose` runs after the test
on the same page so a driver can delete what it seeded; `seeded(name, open, close)` seeds
data once per worker on a throwaway admin page and tears it down after;
`allowFor(floor)` raises the test timeout to a floor and never lowers it.
`test.slow()` is banned in drivers, because it compounds when called in a loop.
- **`drivers/<feature>.ts`** (or `drivers/<feature>/`) exports the feature's
driver, its layers, and the composed `test`. A driver is a plain function
returning an object of closures — no classes, and no `page` parameter on
anything a spec calls.
- **Shared drivers** — `tanstack.ts` (the table), `querysearch.ts` (the
expression editor), `antd.ts` (select primitives), `console.ts`, `api.ts`,
`storage.ts` — are composed into feature drivers by direct call, not by
reaching for another fixture.
- **`drivers/` is AI-owned**: coding agents generate and maintain it with the
specs, and `.gitattributes` collapses it in diffs.
- **`testdata/`** and any `__generated__/` directory hold transcribed data. Both
are marked `linguist-generated`; something in the suite re-derives them at
runtime, so a stale value fails loudly rather than silently.
**Fixture scopes:**
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
- **worker scope** — shared across a worker. Use for read-only data; it pays the
setup cost once per worker instead of once per test. Playwright builds them
lazily, so a spec that never destructures a seed never pays for it.
- **suite scope**: a dataset only one file uses. Seed it in `test.beforeAll`
through `onAdminPage(browser, …)`, since `authedPage` is test-scoped.
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
### Which layer does this test belong to?
```
fixtures/alerts/
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
```
Four layers run against this codebase, in cost order: **jest → Storybook + msw →
pytest integration → Playwright**. Before writing an E2E, check the cheaper
three. [`tests/e2e/AGENTS.md`](../../../tests/e2e/AGENTS.md) has the full rule;
the short version:
Specs import from the fixture they need:
```ts
// List tests — just need rules, no history
import { test, expect } from '../../../fixtures/alerts/alert-rules';
// History tests — need history rows from ruler evaluation
import { test, expect } from '../../../fixtures/alerts/alert-history';
```
**When creating new fixtures:**
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
- A pure function, a reducer, a config literal or a component's own behaviour is
a **jest** test. Most containers under `frontend/src/container/` already have
one.
- A rendered state that needs no backend (empty, error, loading) is a
**Storybook** story or a jest test with a fixed mock.
- Anything the **API** does — ordering, pagination, filter semantics, validation,
roll-ups — is a pytest integration test, and for infra monitoring and alert
evaluation it very likely already exists. Grep `tests/integration/` first.
- An **E2E** is for a real response reaching a real DOM: URL and history, write
paths that must persist, cross-page navigation, and one seeded proof per
surface.
Each spec follows these principles:
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
2. **Test titles use `TC-NN`**: `test('TC-01 alerts page — tabs render', ...)`. Preserves ordering at a glance and maps to external coverage tracking.
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. Reach for direct `page.request.*` only when the test's *purpose* is asserting a response contract (use `page.waitForResponse` on a UI click) or when a specific UI step is structurally flaky (e.g. Ant DatePicker calendar-cell indices) — and even then try UI first.
4. **Self-contained state**: each spec seeds its own data and cleans up at suite teardown. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Patterns, in order of preference:
- **Driver `dispose`** — the driver tracks what it seeded and deletes it after the test, so the spec has no cleanup code. See `dashboards.seed(...)` in `tests/e2e/drivers/dashboards.ts`.
- **Per-test seed + cleanup in `try / finally`** — small specs without a driver, where each test owns its data.
- **Suite-level seed + `afterAll` teardown** — larger specs without a driver. One `test.afterAll(...)` deletes everything the suite seeded. See `tests/e2e/tests/llm-o11y/llm-pricing.spec.ts`. `test.beforeAll` / `test.afterAll` cannot use `authedPage` directly (it's test-scoped); use `newAdminContext(browser)` from `helpers/auth.ts` instead — it performs one fresh login per suite hook.
5. **Seed via API when the UI flow is multi-step or brittle.** The frontend stores its JWT in `localStorage` under `AUTH_TOKEN`; `page.request.*` inherits the auth fixture's storage state. A typical pattern:
```ts
const token = await page.evaluate(
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
await page.request.post('/api/v1/dashboards', {
data: { title: 'my-name', uploadedGrafana: false },
headers: { Authorization: `Bearer ${token}` },
});
```
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, `traces/large-trace.json` is a real trace that `loadLargeTrace` (in `helpers/trace-details.ts`) seeds for the trace-details tests.
3. **UI-first**: drive flows through the UI. Playwright traces capture every BE request/response the UI triggers, so asserting on UI outcomes implicitly validates BE contracts. To assert a write, attach `page.waitForResponse` before the click, then reload and re-read from the server.
4. **Self-contained state**: each spec seeds its own data, scopes its assertions to the names it seeded, and cleans up. The pytest harness creates a fresh stack with **zero** dashboards / alerts / etc. Names carry the worker index, so parallel workers never collide. Pick the scope from the list above.
5. **Seed through the kit when the UI flow is not under test.** `apiDriver(page)` (`drivers/api.ts`) creates control-plane objects with the session's token, and the seeder takes telemetry. The JWT lives in `localStorage`, so a spec never improvises with `page.request.*`. Feature drivers wrap both (`dashboards.seed`, `alerts.seed.threshold`).
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `traces/large-trace.json` is POSTed to the seeder by `drivers/traces.ts`. Infra monitoring reads the integration suite's fixtures through `seedDataset` (`drivers/infra/seed.ts`) instead of keeping a copy, and asserts on what it returned.
7. **Locate by testid, in the driver.** A missing testid is added to the product in the same change. Role, label and CSS lookups are a stopgap, converted as that code is touched; antd internals (`.ant-select-dropdown`) are the exception, since the markup is not ours.
## How to write an E2E test?
Create a new file `tests/e2e/tests/alerts/smoke.spec.ts`:
```typescript
import { test, expect } from '../../fixtures/auth';
import { ALERT_LIST_PAGE_SIZE, expect, test } from '../../drivers/alerts';
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
await page.goto('/alerts');
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
test('TC-01 seeded rules render and paginate', async ({ alerts, alertList }) => {
await alerts.list.goto({ search: alertList.namePrefix });
await expect(alerts.list.table.totalCount()).toContainText(`of ${alertList.count}`);
await expect(alerts.list.rows()).toHaveCount(ALERT_LIST_PAGE_SIZE);
});
```
The `authedPage` fixture (from `tests/e2e/fixtures/auth.ts`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests.
`alertList` is a worker-scoped seed: the rules exist before the test starts, are
shared by every test in the worker that asks for them, and are deleted when the
worker ends.
The `authedPage` fixture (from `tests/e2e/drivers/kit.ts`, re-exported by every feature's `test`) gives you a `Page` whose browser context is already authenticated as the admin user. First use per worker triggers one login; the resulting `storageState` is held in memory and reused for later requests. Take it only when the driver genuinely does not cover what you need — navigation, `goBack`, `waitForResponse`, a second tab.
To run just this test (assuming the stack is up via `test_setup`):
@@ -213,52 +204,56 @@ cd tests/e2e
npx playwright test tests/alerts/smoke.spec.ts --project=chromium
```
Here's a more comprehensive example that exercises a CRUD flow via the UI:
Here's a more comprehensive example, driven entirely through the feature's
driver:
```typescript
import { test, expect } from '../../fixtures/auth';
import { expect, test } from '../../drivers/alerts';
test.describe.configure({ mode: 'serial' });
test('TC-02 row actions persist: disable, then enable', async ({
authedPage: page,
alerts,
ownedRules,
}) => {
const name = `smoke-rule-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
test('TC-02 alerts list — create, toggle, delete', async ({ authedPage: page }) => {
await page.goto('/alerts?tab=AlertRules');
const name = 'smoke-rule';
await alerts.list.goto({ search: name });
await expect(alerts.list.stateCell(ruleId)).toHaveText('OK');
// Seed via UI — click "New Alert", fill form, save.
await page.getByRole('button', { name: /new alert/i }).click();
await page.getByTestId('alert-name-input').fill(name);
// ... fill metric / threshold / save ...
await alerts.list.openFirstRowActions();
await Promise.all([
// waitForResponse captures the call the UI triggers — no parallel fetch.
page.waitForResponse(
(res) =>
res.url().includes(`/api/v2/rules/${ruleId}`) &&
res.request().method() === 'PATCH',
),
alerts.list.menuItem('Disable').click(),
]);
// Find the row and exercise the action menu.
const row = page.locator('tr', { hasText: name });
await expect(row).toBeVisible();
await row.locator('[data-testid="alert-actions"] button').first().click();
// waitForResponse captures the network call the UI triggers — no parallel fetch needed.
const patchWait = page.waitForResponse(
(r) => r.url().includes('/rules/') && r.request().method() === 'PATCH',
);
await page.getByRole('menuitem').filter({ hasText: /^disable$/i }).click();
await patchWait;
await expect(row).toContainText(/disabled/i);
// Re-read from the server rather than trusting the optimistic update.
await page.reload();
await expect(alerts.list.stateCell(ruleId)).toHaveText('Disabled');
});
```
### Locator priority
`ownedRules` is a test-scoped seed: every rule it creates is deleted when the
test finishes, so the spec owns its data and nothing leaks into the next one.
1. `getByTestId('...')` — preferred when the source exposes one. Stable, app-author-provided handle that survives copy-edits.
2. `getByRole('button', { name: 'Submit' })`
3. `getByLabel('Email')`
4. `getByPlaceholder('...')`
5. `getByText('...')`
6. `locator('.ant-select')` — last resort (Ant Design dropdowns often have no semantic alternative)
### Commits
Commit driver changes apart from specs, product testids and tests moved to
another layer, so each commit can be reviewed on its own.
[`tests/e2e/AGENTS.md`](../../../tests/e2e/AGENTS.md#commits) has the order.
`pnpm typecheck`, `pnpm lint` and `pnpm fmt:check` gate the suite.
## Agents
Three Claude agents in `.claude/agents/` accelerate writing and maintaining E2E specs:
- **`playwright-test-planner`** — explores a feature in a real browser plus the local frontend source and writes a test plan as a scratch markdown file (under `tests/e2e/specs/`, which is gitignored — plans are working artifacts for the generator, not committed docs).
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests using the `authedPage` fixture and the API-seed pattern.
- **`playwright-test-generator`** — converts a test plan into Playwright spec files under `tests/e2e/tests/<feature>/`. Drives each scenario through MCP browser tools and emits TC-NN-titled tests on the feature's driver, adding the driver methods and seeds the plan needs.
- **`playwright-test-healer`** — runs failing specs, debugs them with snapshots / console / network introspection, and edits the spec to fix selector drift, timing, or state-leak issues.
The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` tools). Configure it in your Claude MCP settings; the permission allowlist lives in [.claude/settings.local.json](../../../.claude/settings.local.json).
@@ -270,15 +265,15 @@ The agents rely on the Playwright-test MCP server (`mcp__playwright-test__*` too
With the stack already up, from `tests/e2e/`:
```bash
yarn test # headless, all projects
pnpm test # headless, all projects
```
### Running Specific Projects
```bash
yarn test:chromium # chromium only
yarn test:firefox
yarn test:webkit
pnpm test:chromium # chromium only
pnpm test:firefox
pnpm test:webkit
```
### Running Specific Tests
@@ -296,17 +291,17 @@ npx playwright test tests/alerts/history/ --project=chromium
npx playwright test tests/alerts/page.spec.ts --project=chromium
# Single test by title grep
npx playwright test --project=chromium -g "AL-01"
npx playwright test --project=chromium -g "TC-01"
```
### Iterative modes
```bash
yarn test:ui # Playwright UI mode — watch + step through
yarn test:headed # headed browser
yarn test:debug # Playwright inspector, pause-on-breakpoint
yarn codegen # record-and-replay locator generation
yarn report # open the last HTML report (artifacts/html)
pnpm test:ui # Playwright UI mode — watch + step through
pnpm test:headed # headed browser
pnpm test:debug # Playwright inspector, pause-on-breakpoint
pnpm codegen # record-and-replay locator generation
pnpm report # open the last HTML report (artifacts/html)
```
### Staging fallback
@@ -316,7 +311,7 @@ Point `SIGNOZ_E2E_BASE_URL` at a remote env via `.env` — no local backend brin
```bash
cd tests/e2e
cp .env.example .env # fill SIGNOZ_E2E_USERNAME / PASSWORD
yarn test:staging
pnpm test:staging
```
## How to configure different options for E2E tests?
@@ -364,12 +359,14 @@ The same pytest flags integration tests expose work here, since E2E reuses the s
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build on a cold start; later builds are incremental.
- **Changed backend or frontend code? Re-run the setup command** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Seed through the kit, never with `page.request.*`.
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.
- **Title every test `TC-NN <short description>`** — keeps the suite navigable and reportable.
- **Split by resource, not by regression suite.** One spec per feature resource; cross-resource junction concerns (cascade-delete, linked-edit) get their own file.
- **Use short descriptive resource names** (`alerts-list-rule`, `labels-rule`, `downtime-once`) — no timestamp disambiguation. Each test owns its resources and cleans up in `try/finally`.
- **Name resources by what they are, plus the worker index** (`e2e-alert-list-w${workerIndex}-${Date.now()}`). Six workers share one stack, so assert only on names you seeded.
- **Never commit `test.only`** — a pre-commit check or CI runs with `forbidOnly: true`.
- **Prefer explicit waits over `page.waitForTimeout(ms)`.** `await expect(locator).toBeVisible()` is always better than `waitForTimeout(5000)`.
- **Unique test names won't save you from shared-tenant state.** When two tests hit the same list page, either serialize (`describe.configure({ mode: 'serial' })`) or isolate cleanup religiously.
- **Green locally, red on CI?** Run `STRESS=1 CI=1 pnpm test <path>` (4x CPU throttle, 2 workers, 2 retries) before filing a product bug.
- **Commit drivers apart from specs.** One concern per commit; see [Commits](#commits).
- **Artifacts go to `tests/e2e/artifacts/`** — HTML report at `artifacts/html`, traces at `artifacts/results/<test>/`. All gitignored; archive the dir in CI.

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -103,7 +103,7 @@
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -169,7 +169,7 @@
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -5584,6 +5584,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -6716,6 +6722,205 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none; the tooltip still names the column.
*/
label?: string;
/**
* @description auto (default) uses log, or symlog when values include zero or negatives, when that spreads the dots clearly more than linear does; otherwise linear. log cannot place a value at or below zero and leaves that dot out; symlog is log-like but places it.
*/
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
* @description The highest value the axis always shows; data above it still extends the axis, as does a threshold on it. Null fits the data.
*/
softMax?: number | null;
/**
* @type number,null
* @description The lowest value the axis always shows; data below it still extends the axis, as does a threshold on it. Null fits the data. A log axis ignores a bound at or below zero.
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
/**
* @description The axis dimensions.x is plotted along.
*/
x?: DashboardtypesScatterPlotAxisDTO;
/**
* @description The axis dimensions.y is plotted along.
*/
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
/**
* Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
export type DashboardtypesScatterPlotPointsDTOMaxSize = DashboardtypesPointDiameterDTO | null;
/**
* Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
export type DashboardtypesScatterPlotPointsDTOMinSize = DashboardtypesPointDiameterDTO | null;
/**
* Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
export type DashboardtypesScatterPlotPointsDTOOpacity = DashboardtypesPointOpacityDTO | null;
/**
* Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
export type DashboardtypesScatterPlotPointsDTOSize = DashboardtypesPointDiameterDTO | null;
export interface DashboardtypesScatterPlotPointsDTO {
/**
* @description Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
*/
maxSize?: DashboardtypesScatterPlotPointsDTOMaxSize;
/**
* @description Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
*/
minSize?: DashboardtypesScatterPlotPointsDTOMinSize;
/**
* @description Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
*/
opacity?: DashboardtypesScatterPlotPointsDTOOpacity;
/**
* @description Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
*/
size?: DashboardtypesScatterPlotPointsDTOSize;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
/**
* @description Dot size and opacity.
*/
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values color the dots and drive the legend. Empty colors by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales each dot's area between chartAppearance.points.minSize and maxSize. Empty draws every dot at chartAppearance.points.size, as does a dot with no value.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty, or a key the result lacks, uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty, or a key the result lacks, uses the first value column x does not use.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
/**
* @description The axis the line marks: y (default) draws it horizontally, x vertically. Unit converts against that axis's column unit, and the axis extends to keep the line in view.
*/
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
* @description The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work.
*/
color: string;
/**
* @type string
* @description The caption shown on the line.
*/
label?: string;
/**
* @type string
* @description The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms.
*/
unit?: string;
/**
* @type number
* @format double
* @description The value the line is drawn at, in unit.
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
/**
* @description Title, bounds and scale of each axis.
*/
axes?: DashboardtypesScatterPlotAxesDTO;
/**
* @description Dot drawing options.
*/
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
/**
* @description Which result columns place, size and color the dots. Each dot is one row of the joined scalar result: one group.
*/
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
/**
* @description Per-column units and decimal precision. The x and y columns' units label their axes; every bound column's unit shows in the tooltip.
*/
formatting?: DashboardtypesTableFormattingDTO;
/**
* @description Legend placement and series colors. A series is one color group, labeled by its colorBy values joined with a comma, or All when nothing is grouped.
*/
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
* @description Reference lines drawn at fixed values across the x or y axis.
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
/**
* @description The time range the groups are computed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -6726,7 +6931,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6735,6 +6941,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -8006,6 +8213,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
@@ -10770,7 +10978,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
value?: number;
/**
* @type array
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
*/
values?: number[];
}
@@ -11391,7 +11599,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values.
*/
buckets?: number[];
/**
@@ -11512,6 +11720,54 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
unit?: string;
}
export interface Querybuildertypesv5DistributionDTO {
/**
* @type array,null
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
*/
counts?: number[] | null;
/**
* @type array
* @description The group-by field values that identify this distribution.
*/
labels?: Querybuildertypesv5LabelDTO[];
}
export interface Querybuildertypesv5DistributionAggregationDTO {
/**
* @type string
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
*/
alias?: string;
/**
* @type array,null
* @description One distribution per group-by combination.
*/
distributions?: Querybuildertypesv5DistributionDTO[] | null;
/**
* @type integer
* @description The zero-based position of the aggregation in the query.
*/
index?: number;
/**
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
*/
meta?: Querybuildertypesv5AggregationMetaDTO;
}
export interface Querybuildertypesv5DistributionDataDTO {
/**
* @type array,null
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
*/
aggregations?: Querybuildertypesv5DistributionAggregationDTO[] | null;
/**
* @type string
* @description The name of the query that produced this result.
*/
queryName?: string;
}
/**
* The step interval in seconds that was used for each query, keyed by query name.
*/
@@ -11767,21 +12023,28 @@ export type Querybuildertypesv5QueryDataDTO =
| (Querybuildertypesv5TimeSeriesDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5ScalarDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5RawDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
})
| (Querybuildertypesv5DistributionDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
*/
results?: unknown[] | null;
});
@@ -11915,7 +12178,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
}
/**
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
*/
export interface Querybuildertypesv5QueryRangeResponseDTO {
/**
@@ -13264,6 +13527,23 @@ export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export type SpantypesScopeDTOAttributes = { [key: string]: unknown };
export interface SpantypesScopeDTO {
/**
* @type object
*/
attributes?: SpantypesScopeDTOAttributes;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
version?: string;
}
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
@@ -13299,6 +13579,7 @@ export interface SpantypesFlamegraphSpanDTO {
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
scope: SpantypesScopeDTO;
/**
* @type string
*/
@@ -13844,6 +14125,7 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
response_status_code?: string;
scope?: SpantypesScopeDTO;
/**
* @type string
*/

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -274,6 +275,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -2,6 +2,13 @@ import { act, render, screen } from '@testing-library/react';
import LabelColumn from './LabelColumn';
// The global CSS mock maps every class to undefined; keep the names so overflow is assertable.
jest.mock('./LabelColumn.module.scss', () => ({
labelColumn: 'labelColumn',
overflowed: 'overflowed',
shrinkable: 'shrinkable',
}));
const BADGE_WIDTH = 60;
let resizeCallbacks: ResizeObserverCallback[] = [];
@@ -77,6 +84,20 @@ describe('LabelColumn', () => {
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should hide the labels past the visible count', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
// 3 badges (180px) + overflow reserve (40px)
mockLayout(220);
renderWithProviders(<LabelColumn labels={labels} />);
const isHidden = (label: string): boolean =>
screen.getByTestId(`label-tag-${label}`).closest('.overflowed') !== null;
expect(labels.filter(isHidden)).toStrictEqual(['team', 'owner', 'version']);
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };

View File

@@ -10,7 +10,7 @@ interface AuthErrorProps {
function AuthError({ error }: AuthErrorProps): JSX.Element {
return (
<div className="auth-error-container">
<div className="auth-error-container" data-testid="auth-error">
<ErrorContent
error={error}
icon={<CircleAlert size={12} className="auth-error-icon" />}

View File

@@ -512,6 +512,7 @@ export default function CeleryOverviewTable({
allowClear
/>
<ResizeTable
data-testid="mq-overview-table"
className="celery-overview-table"
pagination={paginationConfig}
size="middle"

View File

@@ -28,6 +28,7 @@ function CeleryTaskConfigOptions(): JSX.Element {
Task Name
</Typography.Text>
<Select
data-testid="celery-task-name-select"
placeholder="Task Name"
showSearch
mode="multiple"

View File

@@ -105,7 +105,10 @@ export default function CeleryTaskGraphGrid({
};
return (
<div className="celery-task-graph-grid-container">
<div
className="celery-task-graph-grid-container"
data-testid="celery-graph-grid"
>
<div className="metric-based-graphs">
<CardContainer className="row-card" isDarkMode={isDarkMode}>
<div className="row-panel">

View File

@@ -80,6 +80,7 @@ function CeleryTaskStateGraphConfig({
{tabs.map((tab, index) => (
<Col
key={tab.key}
data-testid={`celery-state-tab-${tab.key}`}
onClick={(): void => handleTabClick(tab.key as CeleryTaskState)}
className={`celery-task-states__tab ${
tab.key === barState ? 'celery-task-states__tab--selected' : ''

View File

@@ -251,6 +251,7 @@ function CustomTimePickerPopoverContent({
<Button
className={cx('data-time-live', isLiveLogsEnabled ? 'active' : '')}
type="text"
data-testid="time-picker-live"
onClick={handleGoLive}
>
Live

View File

@@ -88,6 +88,7 @@ function TimezoneItem({
return (
<button
type="button"
data-testid={`timezone-item-${timezone.value}`}
className={cx('timezone-picker__item', {
selected: isSelected,
'has-divider': timezone.hasDivider,

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { message } from 'antd';
import configureStore from 'redux-mock-store';
import store from 'store';
@@ -194,6 +195,23 @@ describe.each([
});
});
it('sends the chosen jsonl format and 30k row limit', async () => {
renderWithStore(dataSource);
await userEvent.click(screen.getByTestId(testId));
await userEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
await userEvent.click(screen.getByRole('radio', { name: '30k' }));
await userEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
});
const callArgs = mockDownloadExportData.mock.calls[0][0];
expect(callArgs.format).toBe(DownloadFormats.JSONL);
expect(callArgs.body.compositeQuery.queries[0].spec.limit).toBe(
DownloadRowCounts.THIRTY_K,
);
});
it('clears groupBy and having in the export payload', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].groupBy = [

View File

@@ -52,6 +52,7 @@ function DeleteMemberDialog({
variant="solid"
color="danger"
onClick={onConfirm}
testId="member-delete-confirm-btn"
loading={isDeleting}
prefix={<Trash2 size={12} />}
>

View File

@@ -624,6 +624,7 @@ function EditMemberDrawer({
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
size="md"
onClick={(): void => setShowDeleteConfirm(true)}
testId="member-delete-btn"
disabled={isRootUser || isSelf}
variant="link"
color="danger"
@@ -637,6 +638,7 @@ function EditMemberDrawer({
disabledTooltip={ROOT_USER_TOOLTIP}
size="md"
onClick={handleGenerateResetLink}
testId="member-reset-link-btn"
disabled={isRootUser}
loading={isGeneratingLink || isLoadingTokenStatus}
variant="link"
@@ -674,6 +676,7 @@ function EditMemberDrawer({
color="primary"
disabled={!isDirty || isRootUser}
onClick={handleSave}
testId="member-save-btn"
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}

View File

@@ -42,7 +42,12 @@ function ResetLinkDialog({
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
<span
className="reset-link-dialog__link-text"
data-testid="reset-link-text"
>
{resetLink}
</span>
</div>
<Button
size="md"

View File

@@ -1,5 +1,5 @@
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { fireEvent, render, screen } from 'tests/test-utils';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import ExportMenu from '../ExportMenu';
@@ -76,6 +76,17 @@ describe('ExportMenu', () => {
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('exports the picked jsonl format and closes the popover', async () => {
renderMenu();
await userEvent.click(screen.getByTestId(TEST_ID));
await userEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
expect(screen.queryByText('FORMAT')).not.toBeInTheDocument();
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();

View File

@@ -308,7 +308,11 @@ function LogDetailInner({
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
<div
className="log-body"
data-testid="log-detail-body"
dangerouslySetInnerHTML={htmlBody}
/>
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>

View File

@@ -182,6 +182,7 @@ function ListLogView({
return (
<>
<Container
data-testid={`logs-list-row-${logData.id}`}
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
$isDarkMode={isDarkMode}
$logType={logType}

View File

@@ -23,6 +23,7 @@ function LogLinesActionButtons({
icon={<TextSelect size={14} />}
className="show-context-btn"
onClick={handleShowContext}
data-testid="log-line-show-context"
/>
</Tooltip>
<Tooltip title="Copy Link">
@@ -31,6 +32,7 @@ function LogLinesActionButtons({
icon={<Link size={14} />}
onClick={onLogCopy}
className="copy-log-btn"
data-testid="log-line-copy-link"
/>
</Tooltip>
</div>

View File

@@ -156,6 +156,7 @@ function RawLogView({
return (
<RawLogViewContainer
data-testid={`logs-raw-row-${data.id}`}
onClick={handleClickExpand}
wrap={false}
align="middle"

View File

@@ -182,6 +182,7 @@ function OptionsMenu({
<div
className="item"
key={item.label}
data-testid={`format-option-${item.key}`}
onClick={(): void => handleMenuItemClick(item.key)}
>
<div className={cx('item-label')}>

View File

@@ -0,0 +1,72 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { READ_ONLY_TOOLTIP } from '../constants';
import InsertVariableMenu from '../InsertVariableMenu';
import type { EditorVariable } from '../types';
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
{ name: 'service_version' },
];
function renderMenu(disabled = false): jest.Mock {
const onSelect = jest.fn();
render(
<InsertVariableMenu
variables={VARIABLES}
onSelect={onSelect}
disabled={disabled}
/>,
);
return onSelect;
}
function rowNames(): string[] {
return screen
.queryAllByTestId(/^markdown-variable-/)
.map((row) => row.getAttribute('data-testid') ?? '');
}
describe('InsertVariableMenu', () => {
it('narrows the rows to names matching the search', async () => {
renderMenu();
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
await screen.findByTestId('markdown-variable-environment');
expect(rowNames()).toHaveLength(3);
await userEvent.type(screen.getByPlaceholderText('Search variables'), 'SERV');
await waitFor(() =>
expect(rowNames()).toStrictEqual([
'markdown-variable-service',
'markdown-variable-service_version',
]),
);
});
it('hands the clicked variable name to onSelect', async () => {
const onSelect = renderMenu();
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
await userEvent.click(await screen.findByTestId('markdown-variable-service'));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith('service');
});
it('stays closed and explains why when read-only', async () => {
const onSelect = renderMenu(true);
const trigger = screen.getByTestId('markdown-insert-variable');
expect(trigger).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(trigger);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(rowNames()).toHaveLength(0);
await userEvent.hover(trigger);
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
READ_ONLY_TOOLTIP,
);
expect(onSelect).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomMultiSelect from '../CustomMultiSelect';
@@ -678,6 +678,64 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['backend']);
});
it('UI-03c: clicking an option checkbox toggles it exactly once', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendRow = (await screen.findByText('Backend')).closest(
'[role="option"]',
) as HTMLElement;
mockOnChange.mockClear();
await user.click(within(backendRow).getByRole('checkbox'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend', 'backend']);
});
it('UI-03d: clicking an option label toggles it exactly once', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendLabel = await screen.findByText('Backend');
mockOnChange.mockClear();
await user.click(backendLabel);
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend', 'backend']);
});
it('UI-03e: clicking "Toggle" does not also toggle the wrapping checkbox', async () => {
renderWithVirtuoso(
<CustomMultiSelect
options={mockOptions}
onChange={mockOnChange}
value={['frontend', 'backend']}
/>,
);
await user.click(screen.getByRole('combobox'));
const backendRow = (await screen.findByText('Backend')).closest(
'[role="option"]',
) as HTMLElement;
mockOnChange.mockClear();
await user.click(within(backendRow).getByText('Toggle'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange.mock.calls[0][0]).toStrictEqual(['frontend']);
});
it('UI-04: Should display values with loading info at bottom', async () => {
renderWithVirtuoso(
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,

View File

@@ -112,6 +112,7 @@ function ListViewOrderBy({
placeholder="Select a field"
style={{ width: 200 }}
options={selectOptions}
data-testid="list-view-order-by"
filterOption={(input, option): boolean =>
(option?.value ?? '').toLowerCase().includes(input.trim().toLowerCase())
}

View File

@@ -106,9 +106,17 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[resolvedConfig],
);
const traceOperatorField = useMemo(
() =>
resolveQueryBuilderField(QueryBuilderField.TraceOperator, resolvedConfig),
[resolvedConfig],
);
const canShowTraceOperator = showTraceOperator && !traceOperatorField.hidden;
const isMultiQueryAllowed = useMemo(
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
[additionalQueries.hidden, showTraceOperator, isRawQuery],
() => !additionalQueries.hidden && (!isRawQuery || canShowTraceOperator),
[additionalQueries.hidden, canShowTraceOperator, isRawQuery],
);
const queryDataSources = useMemo(
@@ -146,13 +154,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
);
const hasTraceOperator = useMemo(
() => showTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() =>
canShowTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const showQueryList = useMemo(
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
[isRawQuery, showOnlyWhereClause, showTraceOperator],
() => (!showOnlyWhereClause && !isRawQuery) || canShowTraceOperator,
[isRawQuery, showOnlyWhereClause, canShowTraceOperator],
);
const showFormula = useMemo(() => {
@@ -168,8 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
}, [formula.hidden, isRawQuery, currentDataSource]);
const showAddTraceOperator = useMemo(
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
() => canShowTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
// Nothing left to add means no footer at all, rather than an empty bar under the
@@ -178,13 +187,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
() =>
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
((!showOnlyWhereClause && !isRawQuery) ||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
(currentDataSource === DataSource.TRACES && canShowTraceOperator)),
[
additionalQueries.hidden,
showFormula,
showAddTraceOperator,
isRawQuery,
showTraceOperator,
canShowTraceOperator,
showOnlyWhereClause,
currentDataSource,
],
@@ -222,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
version={version}
isAvailableToDisable={false}
@@ -247,7 +256,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
showTraceOperator={showTraceOperator}
showTraceOperator={canShowTraceOperator}
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}

View File

@@ -6,7 +6,8 @@ export type NonAddOnField =
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
| QueryBuilderField.AdditionalQueries
| QueryBuilderField.TraceOperator;
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;

View File

@@ -18,6 +18,7 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
if (isNonMetricSource && isScalarPanelType(panelType)) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -9,7 +9,6 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -27,6 +26,7 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
[dataSource, panelType],
);

View File

@@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { jest } from '@jest/globals';
import { fireEvent, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import {
Having,
@@ -53,6 +56,15 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
let baseQBContext: QueryBuilderContextType;
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
@@ -235,6 +247,71 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
).not.toBeInTheDocument();
});
describe('query actions menu', () => {
const openQueryActions = async (): Promise<void> => {
const trigger = document.querySelector(
'.query-actions-container [aria-haspopup="menu"]',
) as HTMLElement;
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
};
it('Clone calls cloneQuery with the query', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Clone' }),
);
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
expect(baseQBContext.cloneQuery).toHaveBeenCalledWith(
'query',
expect.objectContaining({ queryName: 'A' }),
);
});
it('hides Delete when there is a single query', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await screen.findByRole('menuitem', { name: 'Clone' });
expect(
screen.queryByRole('menuitem', { name: 'Delete' }),
).not.toBeInTheDocument();
});
it('shows Delete with more than one query and deletes on click', async () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const [queryA] = currentQueryBase.builder.queryData;
mockedUseQueryBuilder.mockReturnValue({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
...currentQueryBase.builder,
queryData: [queryA, { ...queryA, queryName: 'B', legend: 'B' }],
},
},
});
const handleDeleteQuery = jest.fn();
mockedUseQueryOperations.mockReturnValue({
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
handleDeleteQuery,
});
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Delete' }),
);
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
});
});
it('fx button is disabled when functions already exist', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const supersetQueryBase = baseQBContext.supersetQuery as Query;

View File

@@ -0,0 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -20,6 +20,7 @@ export enum QueryBuilderField {
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',
TraceOperator = 'trace_operator',
}
/** Per-query builder fields that can be kept equal across a panel's queries. */

View File

@@ -65,13 +65,13 @@ export function resolveQueryBuilderFields(
/**
* The surface a raw-row builder starts from, layered under a caller's own config.
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
* queries when trace matching is on. Omit rather than Partial, so a field added to the
* enum has to be placed on one side.
* `AdditionalQueries` and `TraceOperator` are deliberately absent — a raw trace builder
* still takes several queries, and their trace operator, when trace matching is on.
* Omit rather than Partial, so a field added to the enum has to be placed on one side.
*/
export const RAW_QUERY_FIELDS: Omit<
Record<QueryBuilderField, QueryBuilderFieldRule>,
QueryBuilderField.AdditionalQueries
QueryBuilderField.AdditionalQueries | QueryBuilderField.TraceOperator
> = {
[QueryBuilderField.Aggregation]: { state: 'hidden' },
[QueryBuilderField.StepInterval]: { state: 'hidden' },

View File

@@ -183,7 +183,11 @@ export default function CheckboxFilterV2(
wasSearchShown.current = isSearchShown;
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<div
className={styles.checkboxFilter}
data-testid="checkbox-filter-v2"
data-filter-key={filter.attributeKey.key}
>
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}

View File

@@ -22,7 +22,9 @@ function KeyCreatedPhase({
<div className="add-key-modal__field">
<span className="add-key-modal__label">Key</span>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<span className="add-key-modal__key-text" data-testid="created-key-value">
{createdKey.key}
</span>
<Button
size="md"
variant="link"

View File

@@ -61,6 +61,7 @@ export function RevokeKeyFooter({
color="danger"
loading={isRevoking}
onClick={onConfirm}
testId="revoke-key-confirm-btn"
prefix={<Trash2 size={12} />}
>
Revoke Key

View File

@@ -384,6 +384,7 @@ function ServiceAccountDrawer({
void setIsDeleteOpen(true);
}}
prefix={<Trash2 size={12} />}
testId="sa-delete-btn"
>
Delete Service Account
</AuthZButton>
@@ -409,6 +410,7 @@ function ServiceAccountDrawer({
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
testId="sa-save-btn"
>
Save Changes
</AuthZButton>
@@ -437,6 +439,7 @@ function ServiceAccountDrawer({
type="single"
value={activeTab}
size="sm"
testId="sa-tab"
onChange={(val: string): void => {
if (val) {
void setActiveTab(val as ServiceAccountDrawerTab);
@@ -478,6 +481,7 @@ function ServiceAccountDrawer({
void setIsAddKeyOpen(true);
}}
prefix={<Plus size={12} />}
testId="sa-add-key-btn"
>
Add Key
</AuthZButton>

View File

@@ -263,6 +263,33 @@ describe('useColumnStore', () => {
expect(result.current).toBe(firstSnapshot);
});
it.each([
['only hiddenColumnIds', { hiddenColumnIds: ['persisted'] }],
['only columnOrder', { columnOrder: ['a', 'b'] }],
['an empty object', {}],
['a wrong-typed hiddenColumnIds', { hiddenColumnIds: 'nope' }],
])(
'normalises a persisted value carrying %s so selectors never yield undefined',
(_label, stored) => {
localStorage.setItem(
'@signoz/table-columns/test-table',
JSON.stringify(stored),
);
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
const hidden = renderHook(() => useHiddenColumnIds(TEST_KEY));
const order = renderHook(() => useColumnOrder(TEST_KEY));
const sizing = renderHook(() => useColumnSizing(TEST_KEY));
expect(Array.isArray(hidden.result.current)).toBe(true);
expect(Array.isArray(order.result.current)).toBe(true);
expect(typeof sizing.result.current).toBe('object');
},
);
it('useColumnSizing returns sizing', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);

View File

@@ -66,7 +66,19 @@ const loadTableFromStorage = (tableKey: string): ColumnState | null => {
return cached.parsed;
}
const parsed = JSON.parse(raw) as ColumnState;
// Selectors read these fields directly, so a value persisted under an older
// schema must be filled in rather than handed to callers as `undefined`.
const stored = JSON.parse(raw) as Partial<ColumnState> | null;
const parsed: ColumnState = {
hiddenColumnIds: Array.isArray(stored?.hiddenColumnIds)
? stored.hiddenColumnIds
: [],
columnOrder: Array.isArray(stored?.columnOrder) ? stored.columnOrder : [],
columnSizing:
stored?.columnSizing && typeof stored.columnSizing === 'object'
? stored.columnSizing
: {},
};
persistedTableCache.set(tableKey, { raw, parsed });
return parsed;
} catch {

View File

@@ -0,0 +1,27 @@
import { timeItems } from 'constants/timePreference';
import { render, screen, userEvent } from 'tests/test-utils';
import TimePreference from '../index';
describe('TimePreferenceDropDown', () => {
it('calls setSelectedTime with the clicked item', async () => {
const setSelectedTime = jest.fn();
render(
<TimePreference
selectedTime={timeItems[0]}
setSelectedTime={setSelectedTime}
/>,
);
await userEvent.click(screen.getByRole('button', { name: /Global Time/ }));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Last 15 min' }),
);
expect(setSelectedTime).toHaveBeenCalledTimes(1);
expect(setSelectedTime).toHaveBeenCalledWith({
name: 'Last 15 min',
enum: 'LAST_15_MIN',
});
});
});

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -3,7 +3,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -337,6 +336,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
@@ -591,7 +591,6 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -0,0 +1,83 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ActionsSection from '../ActionsSection';
jest.mock('api/ai-assistant/chat', () => ({
undoExecution: jest.fn(),
revertExecution: jest.fn(),
restoreExecution: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
label: 'Undo change',
actionMetadataId: 'exec-1',
} as MessageActionDTO;
function renderUndoChip(): HTMLElement {
render(<ActionsSection actions={[UNDO_ACTION]} messageId="m1" />);
return screen.getByRole('button', { name: 'Undo change' });
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
});
it('ignores clicks while the rollback is in flight', async () => {
const user = userEvent.setup();
mockUndo.mockReturnValue(new Promise(() => {}));
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).toHaveAttribute('aria-busy', 'true'));
await user.click(chip);
expect(mockUndo).toHaveBeenCalledTimes(1);
expect(mockUndo).toHaveBeenCalledWith('exec-1');
});
it('disables the chip after success and says why', async () => {
const user = userEvent.setup();
mockUndo.mockResolvedValue({} as Awaited<ReturnType<typeof undoExecution>>);
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).toHaveAttribute('aria-disabled', 'true'));
await user.click(chip);
expect(mockUndo).toHaveBeenCalledTimes(1);
// A click closes a Base UI tooltip until the pointer leaves the trigger.
await user.unhover(chip);
await user.hover(chip);
await expect(
screen.findByText('This action already ran'),
).resolves.toBeInTheDocument();
});
it('shows the error text when the rollback fails', async () => {
const user = userEvent.setup();
mockUndo.mockRejectedValue(new Error('Execution already reverted'));
const chip = renderUndoChip();
await user.click(chip);
await waitFor(() => expect(chip).not.toHaveAttribute('aria-busy', 'true'));
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.unhover(chip);
await user.hover(chip);
await expect(
screen.findByText('Execution already reverted'),
).resolves.toBeInTheDocument();
});
});

View File

@@ -18,6 +18,7 @@ import {
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import {
findSavedViewInLists,
@@ -25,7 +26,7 @@ import {
openSavedViewByKey,
} from '../openSavedView';
import {
entityToSavedViewSource,
entityToDataSource,
isSavedViewOpenAction,
resolveActionEntity,
resolveOpenResourceType,
@@ -129,14 +130,10 @@ describe('resolveOpenResource', () => {
).toBe('abc-123');
});
it('maps entity values to saved view sources', () => {
expect(entityToSavedViewSource('logs')).toBe(SavedviewtypesSourceDTO.logs);
expect(entityToSavedViewSource('logs_explorer')).toBe(
SavedviewtypesSourceDTO.logs,
);
expect(entityToSavedViewSource('traces')).toBe(
SavedviewtypesSourceDTO.traces,
);
it('maps entity values to explorer data sources', () => {
expect(entityToDataSource('logs')).toBe(DataSource.LOGS);
expect(entityToDataSource('logs_explorer')).toBe(DataSource.LOGS);
expect(entityToDataSource('traces')).toBe(DataSource.TRACES);
});
it('prefers entity over signal for saved-view source hints', () => {
@@ -147,7 +144,7 @@ describe('resolveOpenResource', () => {
entity: SavedViewEntityDTO.traces,
signal: ApplyFilterSignalDTO.logs,
}),
).toBe(SavedviewtypesSourceDTO.traces);
).toBe(DataSource.TRACES);
});
it('falls back to signal when entity is absent', () => {
@@ -157,7 +154,7 @@ describe('resolveOpenResource', () => {
label: 'Open view',
signal: ApplyFilterSignalDTO.metrics,
}),
).toBe(SavedviewtypesSourceDTO.metrics);
).toBe(DataSource.METRICS);
});
it('normalises saved-view resource types', () => {
@@ -210,10 +207,7 @@ describe('findSavedViewInLists', () => {
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
const result = await findSavedViewInLists(
'view-traces',
SavedviewtypesSourceDTO.traces,
);
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
expect(result).toStrictEqual(tracesView);
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
@@ -281,7 +275,7 @@ describe('openSavedViewByKey', () => {
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
await openSavedViewByKey('view-logs', SavedviewtypesSourceDTO.logs);
await openSavedViewByKey('view-logs', DataSource.LOGS);
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
expect(mockedListSavedViews).not.toHaveBeenCalled();
@@ -293,7 +287,7 @@ describe('openSavedViewByKey', () => {
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
await openSavedViewByKey('view-traces', SavedviewtypesSourceDTO.traces);
await openSavedViewByKey('view-traces', DataSource.TRACES);
expect(mockedListSavedViews).toHaveBeenCalledWith({
source: SavedviewtypesSourceDTO.traces,
@@ -305,8 +299,8 @@ describe('openSavedViewByKey', () => {
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
await expect(
openSavedViewByKey('missing', SavedviewtypesSourceDTO.logs),
).rejects.toThrow('Saved view not found');
await expect(openSavedViewByKey('missing', DataSource.LOGS)).rejects.toThrow(
'Saved view not found',
);
});
});

View File

@@ -2,34 +2,37 @@ import {
getSavedView,
listSavedViews,
} from 'api/generated/services/saved-view';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
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 'container/SavedViews/constants';
import { navigate } from 'lib/router/navigation';
import { DataSource } from 'types/common/queryBuilder';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
const DEFAULT_PROBE_SOURCES: SavedviewtypesSourceDTO[] = [
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.metrics,
type SavedViewSourceHint = SavedViewSourcePage;
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
DataSource.LOGS,
DataSource.TRACES,
DataSource.METRICS,
];
export async function findSavedViewInLists(
viewKey: string,
sourceHint?: SavedviewtypesSourceDTO | null,
sourceHint?: SavedViewSourceHint | null,
): Promise<SavedviewtypesSavedViewDTO | null> {
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
for (const source of sources) {
try {
const response = await listSavedViews({ source });
const response = await listSavedViews({
source: toSavedViewSource(source),
});
const match = findSavedView(response.data, viewKey);
if (match) {
return match;
@@ -44,7 +47,7 @@ export async function findSavedViewInLists(
async function loadSavedView(
viewKey: string,
sourceHint?: SavedviewtypesSourceDTO | null,
sourceHint?: SavedViewSourceHint | null,
): Promise<SavedviewtypesSavedViewDTO> {
try {
const response = await getSavedView({ id: viewKey });
@@ -89,7 +92,7 @@ export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
export async function openSavedViewByKey(
viewKey: string,
sourceHint: SavedviewtypesSourceDTO | null | undefined,
sourceHint: SavedViewSourceHint | null | undefined,
): Promise<void> {
const view = await loadSavedView(viewKey, sourceHint);
openSavedView(view);

View File

@@ -3,7 +3,7 @@ import {
ApplyFilterSignalDTO,
SavedViewEntityDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { ResourceType } from './resourceRoute';
@@ -119,15 +119,15 @@ export function resolveActionEntity(
}
function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
const source = entityToSavedViewSource(value);
const source = entityToDataSource(value);
switch (source) {
case SavedviewtypesSourceDTO.logs:
case DataSource.LOGS:
return SavedViewEntityDTO.logs;
case SavedviewtypesSourceDTO.traces:
case DataSource.TRACES:
return SavedViewEntityDTO.traces;
case SavedviewtypesSourceDTO.metrics:
case DataSource.METRICS:
return SavedViewEntityDTO.metrics;
case SavedviewtypesSourceDTO.meter:
case 'meter':
return SavedViewEntityDTO.meter;
default:
return null;
@@ -135,26 +135,26 @@ function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
}
/**
* Maps an action `entity` to the saved views api source for saved-view lookups.
* Maps an action `entity` to an explorer `DataSource` for saved-view lookups.
* Accepts both short (`logs`) and taxonomy (`logs_explorer`) values.
*/
export function entityToSavedViewSource(
export function entityToDataSource(
entity: SavedViewEntityDTO | string,
): SavedviewtypesSourceDTO | null {
): DataSource | 'meter' | null {
const normalized = entity.trim().toLowerCase().replace(/-/g, '_');
switch (normalized) {
case SavedViewEntityDTO.logs:
case ResourceType.logs_explorer:
return SavedviewtypesSourceDTO.logs;
return DataSource.LOGS;
case SavedViewEntityDTO.traces:
case ResourceType.traces_explorer:
return SavedviewtypesSourceDTO.traces;
return DataSource.TRACES;
case SavedViewEntityDTO.metrics:
case ResourceType.metrics_explorer:
return SavedviewtypesSourceDTO.metrics;
return DataSource.METRICS;
case SavedViewEntityDTO.meter:
return SavedviewtypesSourceDTO.meter;
return 'meter';
default:
return null;
}
@@ -166,10 +166,10 @@ export function entityToSavedViewSource(
*/
export function resolveSavedViewSourceHint(
action: MessageActionDTO,
): SavedviewtypesSourceDTO | null {
): DataSource | 'meter' | null {
const entity = resolveActionEntity(action);
if (entity) {
const fromEntity = entityToSavedViewSource(entity);
const fromEntity = entityToDataSource(entity);
if (fromEntity) {
return fromEntity;
}
@@ -178,11 +178,11 @@ export function resolveSavedViewSourceHint(
if (action.signal) {
switch (action.signal) {
case ApplyFilterSignalDTO.logs:
return SavedviewtypesSourceDTO.logs;
return DataSource.LOGS;
case ApplyFilterSignalDTO.traces:
return SavedviewtypesSourceDTO.traces;
return DataSource.TRACES;
case ApplyFilterSignalDTO.metrics:
return SavedviewtypesSourceDTO.metrics;
return DataSource.METRICS;
default: {
const _exhaustive: never = action.signal;
return _exhaustive;

View File

@@ -340,6 +340,7 @@ function AllErrors(): JSX.Element {
render: (value, record): JSX.Element => (
<Tooltip overlay={(): JSX.Element => value}>
<AppLink
data-testid="exception-row-link"
to={`${ROUTES.ERROR_DETAIL}?groupId=${
record.groupID
}&timestamp=${getNanoSeconds(record.lastSeen)}`}
@@ -486,6 +487,7 @@ function AllErrors(): JSX.Element {
return (
<ResizeTable
data-testid="exceptions-table"
columns={columns}
tableLayout="fixed"
dataSource={data?.payload as Exception[]}

View File

@@ -161,7 +161,7 @@ function DomainDetails({
<Divider type="vertical" />
{domainData?.domainName && (
<Typography.Text className="title">
<Typography.Text className="title" testId="domain-details-title">
{domainData.domainName}
</Typography.Text>
)}
@@ -214,6 +214,7 @@ function DomainDetails({
background: 'var(--l1-background)',
}}
className="domain-detail-drawer"
data-testid="domain-details-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
@@ -233,9 +234,21 @@ function DomainDetails({
value={selectedView}
size="md"
items={[
{ value: VIEW_TYPES.ALL_ENDPOINTS, label: 'All Endpoints' },
{ value: VIEW_TYPES.ENDPOINT_STATS, label: 'Endpoint(s) Stats' },
{ value: VIEW_TYPES.TOP_ERRORS, label: 'Top 10 Errors' },
{
value: VIEW_TYPES.ALL_ENDPOINTS,
label: 'All Endpoints',
testId: `domain-view-${VIEW_TYPES.ALL_ENDPOINTS}`,
},
{
value: VIEW_TYPES.ENDPOINT_STATS,
label: 'Endpoint(s) Stats',
testId: `domain-view-${VIEW_TYPES.ENDPOINT_STATS}`,
},
{
value: VIEW_TYPES.TOP_ERRORS,
label: 'Top 10 Errors',
testId: `domain-view-${VIEW_TYPES.TOP_ERRORS}`,
},
]}
/>
</div>

View File

@@ -176,6 +176,7 @@ function TopErrors({
textPlacement="right"
value={showStatusCodeErrors}
onChange={setShowStatusCodeErrors}
testId="top-errors-status-message-toggle"
/>
<span style={{ color: 'white', fontSize: '14px' }}>
Status Message Exists
@@ -186,7 +187,7 @@ function TopErrors({
</div>
</div>
<div className="endpoints-table-container">
<div className="endpoints-table-container" data-testid="top-errors-table">
<div className="endpoints-table-header">
{showStatusCodeErrors ? 'Errors with Status Message' : 'All Errors'}{' '}
<Tooltip

View File

@@ -64,7 +64,10 @@ function DependentServices({
return (
<div className="top-services-content">
<div className="dependent-services-container">
<div
className="dependent-services-container"
data-testid="dependent-services-table"
>
<Table
loading={isLoading || isRefetching}
dataSource={dependentServicesData || []}

View File

@@ -86,7 +86,7 @@ function DomainMetrics({
}
return (
<div className="domain-detail-drawer__endpoint">
<div className="domain-detail-drawer__endpoint" data-testid="domain-metrics">
<div className="domain-details-grid">
<div className="labels-row">
<Typography.Text color="muted" className="domain-details-metadata-label">
@@ -103,7 +103,7 @@ function DomainMetrics({
</Typography.Text>
</div>
<div className="values-row">
<div className="values-row" data-testid="domain-metrics-values">
<Typography.Text className="domain-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />

View File

@@ -35,7 +35,10 @@ function StatusCodeTable({
}
return (
<div className="status-code-table-container">
<div
className="status-code-table-container"
data-testid="endpoint-status-code-table"
>
<Table
loading={isLoading || isRefetching}
dataSource={statusCodeData || []}

View File

@@ -166,7 +166,10 @@ function DomainList(): JSX.Element {
!isFetching &&
!isLoading &&
formattedDataForTable.length === 0 && (
<div className="no-filtered-domains-message-container">
<div
className="no-filtered-domains-message-container"
data-testid="api-monitoring-empty-state"
>
<div className="no-filtered-domains-message-content">
<img
src={emptyStateUrl}
@@ -200,6 +203,7 @@ function DomainList(): JSX.Element {
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
<Table
className="api-monitoring-domain-list-table"
data-testid="api-monitoring-domain-table"
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
@@ -210,7 +214,10 @@ function DomainList(): JSX.Element {
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onRow={(
record,
index,
): { onClick: () => void; className: string; 'data-testid': string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
@@ -222,6 +229,7 @@ function DomainList(): JSX.Element {
}
},
className: 'expanded-clickable-row',
'data-testid': `domain-row-${record.domainName}`,
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'

View File

@@ -85,7 +85,6 @@ export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfig
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
details: {
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,

View File

@@ -5,6 +5,10 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { CreateAlertProvider } from '../../context';
import {
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../../context/constants';
import AlertThreshold from '../AlertThreshold';
const mockChannels: ChannelOption[] = [];
@@ -289,4 +293,49 @@ describe('AlertThreshold', () => {
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});
it.each([
[
'operator',
THRESHOLD_OPERATOR_OPTIONS,
[
'ABOVE',
'BELOW',
'EQUAL TO',
'NOT EQUAL TO',
'ABOVE OR EQUAL TO',
'BELOW OR EQUAL TO',
],
],
[
'match type',
THRESHOLD_MATCH_TYPE_OPTIONS,
['AT LEAST ONCE', 'ALL THE TIME', 'ON AVERAGE', 'IN TOTAL', 'LAST'],
],
])(
'the %s select offers exactly its documented options',
(_label, options, expected) => {
// Asserted on the options the select is fed rather than on the rendered
// dropdown: antd virtualises its list, so in jsdom (zero-height viewport)
// only the first two items exist in the DOM and their text is the wire
// value, not the label.
expect(options.map((option) => option.label)).toStrictEqual(expected);
renderAlertThreshold();
expect(screen.getByText(options[0].label)).toBeInTheDocument();
},
);
it('never renders the recovery threshold control', () => {
// `showRecoveryThreshold` starts false and its only setter is commented
// out, so neither the input nor its remove button can appear.
renderAlertThreshold();
expect(
screen.queryByTestId('recovery-threshold-value-input'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('remove-recovery-threshold-button'),
).not.toBeInTheDocument();
});
});

View File

@@ -106,6 +106,41 @@ describe('LabelsInput', () => {
expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' });
});
it('rejects a key that already exists, before validating it', () => {
// Both rejection branches raise an antd notification rather than an
// inline message, so the observable outcome is that nothing is added and
// the editor stays on the key phase. The duplicate check short-circuits
// ahead of `validateLabelsKey`.
renderLabelsInput({ labels: { severity: 'high' } });
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'severity' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockValidateLabelsKey).not.toHaveBeenCalled();
expect(mockOnLabelsChange).not.toHaveBeenCalled();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('rejects a key the validator refuses', () => {
mockValidateLabelsKey.mockReturnValue('Cannot use a group-by key');
renderLabelsInput();
fireEvent.click(screen.getByText(ADD_LABELS_TEXT));
const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER);
fireEvent.change(input, { target: { value: 'service.name' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockValidateLabelsKey).toHaveBeenCalledWith('service.name');
expect(mockOnLabelsChange).not.toHaveBeenCalled();
expect(
screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('does not switch to value input if key is empty', () => {
renderLabelsInput();

View File

@@ -173,6 +173,45 @@ describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
});
it('ignores prefill params that are present on an edit URL', () => {
// A deep link into the editor can carry the create form's prefill params.
// The loaded rule has to win, or opening an edited rule from such a link
// would silently rewrite its condition.
const initialAlertState = {
...INITIAL_CREATE_ALERT_STATE,
threshold: {
...INITIAL_CREATE_ALERT_STATE.threshold,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
thresholds: [
{
...INITIAL_CREATE_ALERT_STATE.threshold.thresholds[0],
thresholdValue: 245,
},
],
},
};
render(
<CreateAlertProvider
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
isEditMode
ruleId="r1"
initialAlertState={initialAlertState}
>
<Probe />
</CreateAlertProvider>,
undefined,
{
initialRoute: `/alerts/overview?ruleId=r1&matchType=in_total&compareOp=below&thresholds=${serializeThreshold(
90,
)}`,
},
);
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
expect(screen.getByTestId('match-type')).toHaveTextContent('at_least_once');
});
});
function ThresholdSetter(): JSX.Element {

View File

@@ -148,13 +148,19 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
return (
<div className="error-details-container">
<Typography.Title level={4}>{errorDetail.exceptionType}</Typography.Title>
<Typography.Text>{errorDetail.exceptionMessage}</Typography.Text>
<Typography.Title level={4} testId="error-details-type">
{errorDetail.exceptionType}
</Typography.Title>
<Typography.Text testId="error-details-message">
{errorDetail.exceptionMessage}
</Typography.Text>
<Divider />
<EventContainer>
<div>
<Typography>Event {errorDetail.errorId}</Typography>
<Typography testId="error-details-event-id">
Event {errorDetail.errorId}
</Typography>
<Typography>
{formatTimezoneAdjustedTimestamp(
errorDetail.timestamp,
@@ -165,6 +171,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<div>
<Space align="end" direction="horizontal">
<Button
data-testid="error-details-older"
loading={nextPrevStatus === 'loading'}
disabled={nextPrevData?.payload?.prevErrorID.length === 0}
onClick={(): Promise<void> =>
@@ -177,6 +184,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
{t('older')}
</Button>
<Button
data-testid="error-details-newer"
loading={nextPrevStatus === 'loading'}
disabled={nextPrevData?.payload?.nextErrorID.length === 0}
onClick={(): Promise<void> =>
@@ -194,17 +202,21 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<DashedContainer>
<Typography>{t('see_trace_graph')}</Typography>
<Button onClick={onClickTraceHandler} type="primary">
<Button
data-testid="error-details-view-trace"
onClick={onClickTraceHandler}
type="primary"
>
{t('see_error_in_trace_graph')}
</Button>
</DashedContainer>
<Typography.Title level={4}>{t('stack_trace')}</Typography.Title>
<div className="error-container">
<div className="error-container" data-testid="error-details-stacktrace">
<Editor value={stackTraceValue} readOnly />
</div>
<EditorContainer>
<EditorContainer data-testid="error-details-attributes-table">
<Space direction="vertical">
<ResizeTable columns={columns} tableLayout="fixed" dataSource={data} />
</Space>

View File

@@ -406,4 +406,76 @@ describe('ForgotPassword Component', () => {
expect(submitButton).toHaveAttribute('aria-disabled', 'true');
});
});
// The email input is disabled and Enter in the org Select opens it, so Enter
// only reaches the form through the focused submit button.
describe('Enter key submission', () => {
function mockForgot(delayMs = 0): { forgotCalls: () => number } {
let calls = 0;
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) => {
calls += 1;
return res(
ctx.delay(delayMs),
ctx.status(200),
ctx.json({ status: 'success' }),
);
}),
);
return { forgotCalls: (): number => calls };
}
it('sends the reset link once on Enter on the submit button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { forgotCalls } = mockForgot();
render(<ForgotPassword {...defaultProps} />);
await user.tab();
await user.tab();
expect(screen.getByTestId('forgot-password-submit')).toHaveFocus();
await user.keyboard('{enter}');
await expect(
screen.findByText(/check your email/i),
).resolves.toBeInTheDocument();
expect(forgotCalls()).toBe(1);
});
it('does not send on Enter while no organization is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { forgotCalls } = mockForgot();
render(<ForgotPassword {...multiOrgProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toHaveAttribute('aria-disabled', 'true');
submitButton.focus();
await user.keyboard('{enter}');
// Give a wrongly sent request time to land before asserting it never did.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(forgotCalls()).toBe(0);
});
it('does not send again on Enter while the first request is loading', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { forgotCalls } = mockForgot(1000);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
submitButton.focus();
await user.keyboard('{enter}');
await screen.findByText(/sending\.\.\./i);
expect(submitButton).toHaveAttribute('aria-disabled', 'true');
await user.keyboard('{enter}');
await expect(
screen.findByText(/check your email/i, undefined, { timeout: 2000 }),
).resolves.toBeInTheDocument();
expect(forgotCalls()).toBe(1);
});
});
});

View File

@@ -219,7 +219,12 @@ export default function AlertRules({
<Card className="alert-rules-card home-data-card">
{rulesExist && (
<Card.Header>
<div className="alert-rules-header home-data-card-header">Alerts</div>
<div
className="alert-rules-header home-data-card-header"
data-testid="home-section-alerts"
>
Alerts
</div>
</Card.Header>
)}
<Card.Content>

View File

@@ -202,7 +202,12 @@ export default function Dashboards({
<Card className="dashboards-list-card home-data-card">
{dashboardsExist && (
<Card.Header>
<div className="dashboards-header home-data-card-header">Dashboards</div>
<div
className="dashboards-header home-data-card-header"
data-testid="home-section-dashboards"
>
Dashboards
</div>
</Card.Header>
)}
<Card.Content>

View File

@@ -278,7 +278,7 @@ export default function Home(): JSX.Element {
}, []);
return (
<div className="home-container">
<div className="home-container" data-testid="home-page">
{getIsNoAuthMode() && <NoAuthBanner />}
<div className="sticky-header">
<Header
@@ -316,6 +316,7 @@ export default function Home(): JSX.Element {
variant="solid"
color="secondary"
prefix={<ClipboardList size={14} />}
testId="welcome-checklist-btn"
>
Welcome checklist
</Button>
@@ -662,7 +663,10 @@ export default function Home(): JSX.Element {
exit={{ opacity: 0, scale: 0 }}
key="box"
>
<div className="checklist-container">
<div
className="checklist-container"
data-testid="home-checklist-card"
>
<div className="checklist-items-container">
<StepsProgress checklistItems={checklistItems} />
@@ -701,6 +705,7 @@ export default function Home(): JSX.Element {
color="secondary"
onClick={handleWillDoThisLater}
loading={updatingUserPreferences}
testId="checklist-do-later-btn"
>
I&apos;ll do this later
</Button>

View File

@@ -68,6 +68,7 @@ function HomeChecklist({
{whatsNextChecklistItems.map((item, index) => (
<div
key={item.id}
data-testid={`checklist-item-${item.id}`}
className={`whats-next-checklist-item ${
item.isSkipped && !item.completed ? 'skipped' : ''
} ${
@@ -129,6 +130,7 @@ function HomeChecklist({
<Button
type="link"
className="periscope-btn link skip-btn"
data-testid={`checklist-skip-${item.id}`}
onClick={(): void => {
logEvent('Welcome Checklist: Skip clicked', {
step: item.id,

View File

@@ -301,7 +301,10 @@ export default function SavedViews({
<Card className="saved-views-list-card home-data-card">
{hasSavedViews && (
<Card.Header>
<div className="saved-views-header home-data-card-header">
<div
className="saved-views-header home-data-card-header"
data-testid="home-section-saved-views"
>
Saved Views
<div className="saved-views-header-actions">
<Button.Group className="views-tabs">

View File

@@ -315,7 +315,10 @@ function ServiceMetrics({
<Card className="services-list-card home-data-card">
{servicesExist && (
<Card.Header>
<div className="services-header home-data-card-header">
<div
className="services-header home-data-card-header"
data-testid="home-section-services"
>
{' '}
Services
<div className="services-header-actions">

View File

@@ -218,7 +218,10 @@ export default function ServiceTraces({
<Card className="dashboards-list-card home-data-card">
{servicesExist && (
<Card.Header>
<div className="services-header home-data-card-header">
<div
className="services-header home-data-card-header"
data-testid="home-section-services"
>
Services
<div className="services-header-actions">
<Select

View File

@@ -213,6 +213,7 @@ function Hosts(): JSX.Element {
className="periscope-btn ghost"
type="text"
size="small"
data-testid="quick-filters-toggle"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
@@ -239,6 +240,7 @@ function Hosts(): JSX.Element {
style={{ rotate: '270deg', cursor: 'pointer' }}
onClick={handleFilterVisibilityChange}
size="md"
data-testid="quick-filters-toggle"
/>
</Tooltip>
</div>

View File

@@ -53,6 +53,7 @@ function StatusFilter(): JSX.Element {
items={statusOptions.map((option) => ({
value: option.value,
label: option.label,
testId: `status-filter-${option.value}`,
prefix: (
<span
className={`${styles.statusDot} ${

View File

@@ -296,6 +296,7 @@ export default function K8sBaseDetails<T>({
<DrawerWrapper
open={!!selectedItem}
onOpenChange={handleOpenChange}
testId="drawer-wrapper"
direction="right"
title={drawerTitle}
showCloseButton={false}

View File

@@ -301,6 +301,7 @@ export default function K8sBaseDetailsContent<T>({
type="single"
onChange={handleTabChange}
value={effectiveView}
testId="drawer-tab-bar"
items={[
...(tabVisibility.showOverview
? [
@@ -387,6 +388,7 @@ export default function K8sBaseDetailsContent<T>({
size="sm"
icon
color="secondary"
testId="open-logs-explorer"
onClick={handleExplorePagesRedirect}
>
<Compass size={18} />
@@ -401,6 +403,7 @@ export default function K8sBaseDetailsContent<T>({
size="sm"
icon
color="secondary"
testId="open-traces-explorer"
onClick={handleExplorePagesRedirect}
>
<Compass size={18} />

View File

@@ -396,6 +396,11 @@ export function K8sBaseList<
const isGroupedByAttribute = groupBy.length > 0;
const getRowTestId = useCallback(
(record: T): string => `row-${getRowKey(record)}`,
[getRowKey],
);
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
@@ -417,6 +422,7 @@ export function K8sBaseList<
extraQueryKeyParts={extraQueryKeyParts}
getRowKey={getRowKey}
getItemKey={getItemKey}
getRowTestId={getRowTestId}
detailsQueryKeyPrefix={detailsQueryKeyPrefix}
/>
),
@@ -425,6 +431,7 @@ export function K8sBaseList<
fetchListData,
getRowKey,
getItemKey,
getRowTestId,
expandedRowColumns,
extraQueryKeyParts,
detailsQueryKeyPrefix,
@@ -506,6 +513,7 @@ export function K8sBaseList<
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
getRowTestId={getRowTestId}
groupBy={groupBy.map((g) => ({ key: g }))}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}

View File

@@ -21,7 +21,7 @@ export function K8sEmptyState({
if (isError || error) {
return (
<div className={styles.container}>
<div className={styles.container} data-testid="k8s-error-state">
<div className={styles.errorContent}>
<ErrorContent
error={
@@ -38,7 +38,7 @@ export function K8sEmptyState({
if (endTimeBeforeRetention) {
return (
<div className={styles.container}>
<div className={styles.container} data-testid="k8s-empty-state-retention">
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<div className={styles.noDataMessage}>
@@ -56,7 +56,7 @@ export function K8sEmptyState({
}
return (
<div className={styles.container}>
<div className={styles.container} data-testid="k8s-empty-state">
<div className={styles.content}>
<img
src={emptyStateUrl}

View File

@@ -69,6 +69,7 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => TItemKey;
getRowTestId?: (record: T) => string;
/** Query key prefix for pre-caching detail data on row click */
detailsQueryKeyPrefix?: string;
};
@@ -85,6 +86,7 @@ export function K8sExpandedRow<
extraQueryKeyParts = [],
getRowKey,
getItemKey,
getRowTestId,
detailsQueryKeyPrefix,
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
const fontSize = useInfraMonitoringFontSize();
@@ -321,6 +323,7 @@ export function K8sExpandedRow<
isLoading={isLoading}
getRowKey={getRowKey}
getItemKey={getItemKey}
getRowTestId={getRowTestId}
onRowClick={handleRowClick}
onSort={handleSort}
enableQueryParams={{

View File

@@ -141,6 +141,7 @@ function K8sHeader({
isLoadingQueries={isFetching}
onStageRunQuery={handleStageRunQuery}
handleCancelQuery={handleCancelQuery}
testId="list-run-query-btn"
/>
</div>

View File

@@ -4,8 +4,7 @@ import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';

View File

@@ -51,7 +51,7 @@ function EntityDateTimeSelector({
);
return (
<div className={styles.container}>
<div className={styles.container} data-testid="drawer-time-selection">
{hasTimeChanged && (
<Tooltip title="Reset to list time" side="bottom">
<Button

View File

@@ -12,7 +12,7 @@ export default function EntityEmptyState({
hasFilters,
}: EntityEmptyStateProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.container} data-testid="entity-empty-state">
<div className={styles.content}>
<img src={emptyStateUrl} alt="empty-state" className={styles.icon} />
{hasFilters ? (

View File

@@ -20,7 +20,7 @@ export default function EntityError(): JSX.Element {
};
return (
<div className={styles.container}>
<div className={styles.container} data-testid="entity-error-state">
<div className={styles.content}>
<img src={awwSnapUrl} alt="error" className={styles.icon} />
<Typography.Text>

View File

@@ -242,6 +242,7 @@ function EntityEventsContent({
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
testId="entity-events-run-query-btn"
/>
</div>

View File

@@ -15,7 +15,7 @@ export default function EventsNotConfigured(): JSX.Element {
};
return (
<div className={styles.container}>
<div className={styles.container} data-testid="events-not-configured">
<div className={styles.content}>
<img src={emptyStateUrl} alt="not-configured" className={styles.icon} />
<Typography.Text>

View File

@@ -287,6 +287,7 @@ function EntityLogsContent({
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
testId="entity-logs-run-query-btn"
/>
</div>

View File

@@ -152,13 +152,16 @@ function EntityMetrics<T>({
}
const panelType = queryPayloads[idx]?.graphType;
const isNoData =
!query.isLoading && !query?.data?.payload?.data?.result?.length;
return (
<div
className={cx(styles.chartContainer, {
[styles.noDataContainer]:
!query.isLoading && !query?.data?.payload?.data?.result?.length,
[styles.noDataContainer]: isNoData,
})}
data-testid="entity-metrics-chart"
data-has-data={isNoData ? 'false' : 'true'}
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable

View File

@@ -181,6 +181,7 @@ function EntityTracesContent({
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
testId="entity-traces-run-query-btn"
/>
</div>

View File

@@ -257,6 +257,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
size="sm"
icon
color="secondary"
testId="quick-filters-toggle"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
@@ -287,6 +288,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
data-testid="quick-filters-toggle"
/>
</Tooltip>
</div>

View File

@@ -12,9 +12,11 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';

View File

@@ -0,0 +1,155 @@
import { hostColumnsConfig } from 'container/InfraMonitoringHostsV2/table.config';
import { k8sClustersColumnsConfig } from '../Clusters/table.config';
import {
ENTITY_FILTER_PLACEHOLDERS,
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from '../constants';
import { k8sDaemonSetsColumnsConfig } from '../DaemonSets/table.config';
import { k8sDeploymentsColumnsConfig } from '../Deployments/table.config';
import { k8sJobsColumnsConfig } from '../Jobs/table.config';
import { k8sNamespacesColumnsConfig } from '../Namespaces/table.config';
import { k8sNodesColumnsConfig } from '../Nodes/table.config';
import { k8sPodColumnsConfig } from '../Pods/table.config';
import { k8sStatefulSetsColumnsConfig } from '../StatefulSets/table.config';
import { k8sVolumesColumnsConfig } from '../Volumes/table.config';
/**
* The per-entity config, asserted from the source rather than through a browser.
*
* `K8sBaseList` and `K8sBaseDetails` do not branch on entity — the only thing
* that varies between the ten is the config object they are handed. So the
* question "is entity N wired up correctly" is a property of these literals, and
* running the same rendered list ten times over a seeded stack answers it at
* about a thousand times the cost. The E2E suite drives one entity; this is what
* covers the other nine.
*/
interface EntityConfig {
entity: InfraMonitoringEntity;
/** The column whose id is the entity's name attribute. */
nameColumnId: string;
/** The group-row column, which every entity renders first and pins left. */
groupColumnId: string;
columns: { id: string; enableSort?: boolean; pin?: string }[];
}
const ENTITIES: EntityConfig[] = [
{
entity: InfraMonitoringEntity.HOSTS,
nameColumnId: 'host.name',
groupColumnId: 'hostGroup',
columns: hostColumnsConfig,
},
{
entity: InfraMonitoringEntity.PODS,
nameColumnId: 'k8s.pod.name',
groupColumnId: 'podGroup',
columns: k8sPodColumnsConfig,
},
{
entity: InfraMonitoringEntity.NODES,
nameColumnId: 'k8s.node.name',
groupColumnId: 'nodeGroup',
columns: k8sNodesColumnsConfig,
},
{
entity: InfraMonitoringEntity.NAMESPACES,
nameColumnId: 'k8s.namespace.name',
groupColumnId: 'namespaceGroup',
columns: k8sNamespacesColumnsConfig,
},
{
entity: InfraMonitoringEntity.CLUSTERS,
nameColumnId: 'k8s.cluster.name',
groupColumnId: 'clusterGroup',
columns: k8sClustersColumnsConfig,
},
{
entity: InfraMonitoringEntity.DEPLOYMENTS,
nameColumnId: 'k8s.deployment.name',
groupColumnId: 'deploymentGroup',
columns: k8sDeploymentsColumnsConfig,
},
{
entity: InfraMonitoringEntity.STATEFULSETS,
nameColumnId: 'k8s.statefulset.name',
groupColumnId: 'statefulSetGroup',
columns: k8sStatefulSetsColumnsConfig,
},
{
entity: InfraMonitoringEntity.DAEMONSETS,
nameColumnId: 'k8s.daemonset.name',
groupColumnId: 'daemonSetGroup',
columns: k8sDaemonSetsColumnsConfig,
},
{
entity: InfraMonitoringEntity.JOBS,
nameColumnId: 'k8s.job.name',
groupColumnId: 'jobGroup',
columns: k8sJobsColumnsConfig,
},
{
entity: InfraMonitoringEntity.VOLUMES,
nameColumnId: 'k8s.persistentvolumeclaim.name',
groupColumnId: 'volumeGroup',
columns: k8sVolumesColumnsConfig,
},
];
describe('infra monitoring entity config', () => {
it.each(ENTITIES)(
'$entity has a filter placeholder and a metric namespace',
({ entity }) => {
// A missing placeholder renders an empty search box; a missing namespace
// makes the field-suggestion calls return everything.
expect(ENTITY_FILTER_PLACEHOLDERS[entity]).toMatch(
/^Enter your filter query/,
);
expect(METRIC_NAMESPACE_BY_ENTITY[entity]).toMatch(/\.$/);
},
);
it.each(ENTITIES)(
'$entity has unique, non-empty column ids',
({ columns }) => {
const ids = columns.map((column) => column.id);
expect(ids.every(Boolean)).toBe(true);
// A duplicate id makes TanStack drop one of the columns silently, and the
// persisted hidden/order state then addresses the wrong one.
expect(new Set(ids).size).toBe(ids.length);
},
);
it.each(ENTITIES)(
'$entity pins its group and name columns left',
({ columns, groupColumnId, nameColumnId }) => {
// Both are `pin: 'left'` so they survive a horizontal scroll; the group
// column is also what the expand chevron lives in.
const byId = new Map(columns.map((column) => [column.id, column]));
expect(byId.get(groupColumnId)?.pin).toBe('left');
expect(byId.get(nameColumnId)?.pin).toBe('left');
},
);
it.each(ENTITIES)(
'$entity sorts by its name column and not by its group column',
({ columns, groupColumnId, nameColumnId }) => {
const byId = new Map(columns.map((column) => [column.id, column]));
expect(byId.get(nameColumnId)?.enableSort).toBe(true);
// The group column holds an aggregate, so there is nothing to sort by.
expect(byId.get(groupColumnId)?.enableSort).toBe(false);
},
);
it('every entity with a config is covered here', () => {
// Containers is in the enum with fixtures behind it, but has no entity
// config and no tab yet — the day it ships, this fails.
const covered = new Set(ENTITIES.map(({ entity }) => entity));
const missing = Object.values(InfraMonitoringEntity).filter(
(entity) => !covered.has(entity),
);
expect(missing).toStrictEqual([InfraMonitoringEntity.CONTAINERS]);
});
});

View File

@@ -26,6 +26,8 @@ export function ExpandButtonWrapper({
aria-label={localIsExpanded ? 'Collapse' : 'Expand'}
variant="ghost"
color="secondary"
testId="expand-row-button"
aria-expanded={localIsExpanded}
onClick={(e): void => {
e.stopPropagation();
setLocalIsExpanded((v) => !v);

View File

@@ -111,7 +111,7 @@ export function GroupedStatusCounts({
}
return (
<div className={styles.container}>
<div className={styles.container} data-testid="grouped-status-counts">
{visibleItems.map((item) => (
<TanStackTable.HoverTooltip
key={item.label}

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