Compare commits

..

16 Commits

Author SHA1 Message Date
Tushar Vats
ca7daf8ea9 fix(querier): skip offset rows without decoding them (#13564)
Some checks are pending
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
#### Description

- Raw lists sorted by timestamp (logs: `timestamp, id`) fetch `offset +
limit` rows per chunk and drop the first `offset` in Go. Every dropped
row was first decoded into a `RawRow` (body + attribute maps), so deep
pages loaded every skipped row into memory and could OOM signoz-0.
- `readAsRawSkipping` now steps past the skipped rows with `rows.Next()`
without `Scan`, so they are never decoded. Memory stays at about one
page plus one ClickHouse block.
- `executeWindowList` passes the remaining offset to each chunk and
counts the skipped rows against `need` and `offsetLeft`.

#### Issues closed by this PR

Fixes: SigNoz/events-pod#270
Part of: SigNoz/events-pod#269

#### Additional Information

- Skipped rows still cross the wire. Pushing `OFFSET` into ClickHouse is
point 1 of the issue.
- `TestReadAsRawSkipping` uses `clickhouse-go-mock` rows (v0.15.1,
bumped in SigNoz/signoz#13580), whose `Next()` advances like the real
driver. Each skipped row is marked with `RowError`, so the test fails if
a skipped row is ever scanned.
- New integration tests `test_traces_list_offset_across_chunks` and
`test_logs_list_offset_across_chunks` page a 4h list with offsets
landing in, and crossing into, each of its three time chunks.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:20:30 +00:00
Swapnil Nakade
d038407475 feat(session): add resource defs to session routes (#13563)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add resource defs to session routes
- why? - so that auditor can pick up these routes for auditing
- session routes are pre-auth so audit events will be dropped at the
zero org id condition
- rotate and delete session (when session is active) routes are audited

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/10
2026-11-13 17:16:26 +00:00
Tushar Vats
da3719ec7e chore(deps): bump clickhouse-go-mock to v0.15.1 (#13580)
#### Description

- Bumps `github.com/SigNoz/clickhouse-go-mock` v0.14.0 → v0.15.1. Its
`Rows.Next()` now advances like the real driver (skipping a row with
`Next()` and no `Scan()` works), and the new `Rows.RowError` fails
`Scan` of a chosen row (SigNoz/clickhouse-go-mock#14).
- v0.15.1 requires clickhouse-go v2.47.0, so this also raises
**production** clickhouse-go 2.44.0 → 2.47.0 and ch-go 0.71.0 → 0.73.0,
with transitive bumps (zap 1.28.0, otelhttp 0.69.0, gopsutil 4.26.5,
lz4, orb, brotli, purego, go-version, go-sysconf, numcpus, plan9stats).
- Kept apart from SigNoz/signoz#13564 so the driver upgrade can be
reverted on its own.

#### Issues closed by this PR

Part of: SigNoz/events-pod#270

#### Additional Information

- clickhouse-go 2.45–2.47: positional binding now skips `?` inside
string literals, quoted identifiers and comments (`--`, `/* */`, `#`)
(ClickHouse/clickhouse-go#1879, #1888). Our builders emit no SQL
comments and no `?` inside quotes, and values inlined by
`pkg/clickhousesql` that contain `?` were previously bound as
placeholders; now they stay literal. `\?` handling is unchanged. Other
fixes: JSON column serialization version (#1850), DateTime/DateTime64
time zones (#1882), connection eviction on wrapped net errors (#1869).
- ch-go 0.73.0 drops the obsolete nested-exception flag
(ClickHouse/ch-go#1159, marked breaking): `proto.Exception.Nested` is
gone. We only use ch-go's error-code constants, and clickhouse-go's own
`Exception` is unchanged between 2.44.0 and 2.47.0.
- Tested: `go build ./...`, `go test ./...` (all pass), `go vet ./...`
(same 38 findings as `main`, none new). Integration suites
`querierlogs`, `queriertraces` and `querier_json_body` against a server
built with these versions: 624 pass (5 first hit ClickHouse memory
limits in the shared local test container while inserting fixtures, and
passed on rerun).
- Follow-up: #13564 switches `TestReadAsRawSkipping` to `cmock` +
`RowError` and drops its mockery driver mocks once this merges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-13 17:13:24 +00:00
Gaurav Tewari
99745b7dba fix(ai-assistant): open explorer chips on the answer's time range (#13363)
#### Description
- Noz's explorer chips carried the query but not its time window, so the
Explorer opened on the current picker value (e.g. Last 6 hours) instead
of the window the answer was computed for.
- Chips now apply the action's `start`/`end` as an absolute range:
off-page via `startTime`/`endTime` on the Explorer URL; on an
already-open Explorer by also setting the global time and dropping the
stale `relativeTime`.
- Without a valid range nothing changes: query and filters hand over,
time stays as is.

#### Issues closed by this PR
Closes SigNoz/nerve-pod#174

#### Screenshots / Screen Recordings
Prev -


https://github.com/user-attachments/assets/9d54125f-ed32-486e-acc2-2d1c59963fa5

 


Now -




https://github.com/user-attachments/assets/1ce75dc0-d62a-430c-b458-ca1036459955




#### Additional Information
- Stacked on #13557, which carries the `redirectWithQueryBuilderData`
change this relies on.
- The Explorers' global time is Redux-only, so `useDispatch` goes behind
the existing `no-restricted-imports` escape hatch.
- Time params are not written with nuqs: the Explorers' time is Redux +
react-router URL writes, and a second nuqs writer adds a history entry
or leaves react-router with a stale `relativeTime` (see
`utils/timeUrlParams.ts`). Once the Explorers move to
`store/globalTime`, the chip can call `setSelectedTime` and drop its URL
handling.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 17:10:07 +00:00
Swapnil Nakade
fb60ae1594 refactor: using signoz otel collector image for ch migration (#13517)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using signoz otel collector image for ch migration
- adding auditquerier in test suite in ci
- added v0.144.16-rc.1 temporarily
2026-11-13 16:53:14 +00:00
Gaurav Tewari
d099467285 feat(query-builder): drop null params and forward navigate options on redirect (#13557)
#### Description
- `redirectWithQueryBuilderData` deletes a search param passed as `null`
instead of writing `"null"`, so one navigation can swap params (e.g.
`relativeTime` for `startTime`/`endTime`).
- Its trailing `newTab` flag becomes `navigateOptions: { newTab, replace
}`, passed straight to `safeNavigate`.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- Split out of #13363. Stacked on #13556 only to keep #13363's diff
clean; it doesn't depend on it.
- The `source: null` reset in `CreateAlertV2/QuerySection` now removes
`source` instead of writing `source=null`; its readers only compare
against `DASHBOARDS`, so behavior is the same.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 15:51:53 +00:00
Swapnil Nakade
445e3cb088 feat(user): add resource defs to self-service user routes (#13574)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add resource defs to the self-service user routes: get and update my
user, change my password, forgot password, reset password, verify reset
token
- why? so that the auditor can pick up these routes for auditing
- update my user and change my password are audited today
- forgot and reset password are pre-auth, so their audit events are
dropped at the zero org id check until the principal is resolvable
- routes stay open access, no API or OpenAPI spec change

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/10
2026-11-13 14:58:58 +00:00
Nikhil Mantri
0fbd56058f fix(authz): keep rule validation errors behind the rule FGA check (#13576)
#### Description

- Fixes `ruler/08_rule_validation.py` failing on main after #13253,
since the rule authz check runs before the handler's validation.
- Malformed rule JSON returned 500 from the channel extractor, it now
decodes strictly like the handler and returns 400.
- Channel attach checks skip names that don't exist, so the handler's
`validateChannels` returns its clearer error (e.g. channel referenced by
name instead of display name). Existing channels still need attach.
- A malformed rule id now gets the generic `invalid_selector` 400 like
every other FGA route, test updated.
2026-11-13 14:54:47 +00:00
Nikhil Soni
0f598c49ad test(traces): anchor attribute evolution tests to the migrator's rollout (#13551)
#### Description
- Schema migrator `v0.144.13+` (migration 1002) seeds the traces
`attributes` JSON rollout at install, as it already does for resources.
Tests that wrote their own rollout rows broke on it: `[map]` cases read
JSON, and the seed teardown deleted the migrator's row
(`test_summary_ai_trace[map]` failing on #13517).
- Bumps the schema migrator to `v0.144.15` (CI, tests, devenv). Tests no
longer write `column_evolution_metadata`; rollout-straddle tests anchor
to the migrator's time via `get_attribute_evolution_time`, mirroring
`get_resource_evolution_time`.
- Map/JSON dual run kept: `[map]` anchors spans and query window an hour
before the rollout, where the querier reads only the legacy maps, the
same SQL as with `use_trace_attributes_json` off. `queriertraces` raises
the flux interval so those past windows aren't served from each other's
cached buckets.

#### Additional Information
- Unblocks #13517, which switches migrations to the otel-collector
image.

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

---------

Co-authored-by: swapnil-signoz <swapnil@signoz.io>
2026-11-13 13:04:16 +00:00
Gaurav Tewari
b780551de6 fix(navigation): keep an explicit replace: false in safeNavigate (#13556)
#### Description
- `safeNavigate` replaced the history entry whenever the target added a
param the current URL lacked, even when the caller passed `replace:
false`. An explicit `replace` now wins; without one, behavior is
unchanged.
- No caller passes `replace: false` today, so nothing changes until one
opts in.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- Split out of #13363, whose Noz chips need a history entry Back can
return to when they add `startTime`/`endTime` on an open Explorer.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 11:32:14 +00:00
Nikhil Mantri
8bc4c5f79c feat(authz): rule FGA (#13253)
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
Release Drafter / update_release_draft (push) Has been cancelled
cacheci / tests (push) Has been cancelled
#### Description

- Move `/api/v2/rules*`, `/api/v3/rules` and `/api/v2/rules/test` from
role gates to `CheckResources` on rule verbs.
- Create, update, patch, clone and test also check attach and detach on
the notification channels the rule names, diffed against the stored
rule. Unknown channel names are rejected with 400.
- Create, update, patch, clone and test also check read on the telemetry
the rule's query touches, so a rule cannot be used to read signals the
caller cannot query directly. Managed roles already hold these reads,
only custom roles are affected.
- PATCH checks telemetry read only when the body carries
`condition.compositeQuery`, so disabling or renaming a rule needs no
read on its stored query. This adds an opt-in `Optional` flag on
`TelemetryResourceDef` for that one route, every other telemetry def
keeps failing closed.
- Detach of a channel name that no longer exists is skipped instead of
rejected, so a rule whose channel was removed can still be updated.
Attach stays strict.
- Integration tests cover viewer, editor and a custom role on every
gate. No migration, rule tuples come from #13251 and channel links from
#13249.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#286
Closes SigNoz/pulse-pod#355

#### Additional Information

Fifth of five stacked PRs splitting #12927. Stacked on #13252.
2026-11-13 09:15:38 +00:00
Naman Verma
198230f064 fix: remove algorithm from all rule examples and request builders (#13567)
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/732
2026-11-13 09:11:00 +00:00
Aswin K.M.
0791306e52 feat(build-staging): release staging through the deployments repo on merges and labels (#13571)
#### Description

- Merges to `main` now build and release the staging base
(`overlays/staging/*/signoz/base`), which every staging overlay
inherits.
- `staging:<deployment>` labels on PRs still release only that
deployment's overlay.
- Releases go to `signoz.staging.cl.gcp.deployments` with a `{path,
image, tag}` payload instead of `{deployment, signoz_version, source}`.
- The deployment label is checked against
`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` before it goes into the path glob and
the JSON payload.
- `checkout`, `create-github-app-token` and `cache` are pinned to
release SHAs.
2026-11-13 09:04:43 +00:00
Ashwin Bhatkal
792de1376d test(storybook): make plays pass on every control value (#13538)
#### Description

- The plays on the alert rules page, the dashboards list and the
metrics, traces, logs and pods explorers now check the args they depend
on: whether rows are listed, which permissions are held, whether the
data loaded, which tab, view or format is open, and how a write answers.
Run once against every control value, 167 of 969 alert rules runs, 336
of 1,372 dashboards list runs and 212 of about 2,200 explorer runs
failed on `main`. All of them pass now.
- The explorer pages' plays moved out of their story files into play
files, as the skill asks. Plays that had become the same play under a
different control value are now one.
- `grantsAll` in `src/storybook/access/access.ts` answers a permission
check from a story's access controls, the same way the `authz/check`
mock does.
- The `signoz-page-story` skill gets a **Plays** section listing the
cases these plays missed and how to guard them with the page's own code,
and `.claude/rules/storybook.md` points at it. CI runs each play only
with its story's own args, so these rules are what keeps a new play from
missing the same cases.
- No screen renders differently: only plays, their mock helpers and the
skill changed.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/727
2026-11-13 06:05:06 +00:00
Ashwin Bhatkal
20e6d44289 fix(api): send array query params as repeated keys (#13457)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

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

#### Issues closed by this PR

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

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

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/726
2026-11-12 15:46:37 +00:00
115 changed files with 4734 additions and 2169 deletions

View File

@@ -11,7 +11,7 @@ Page stories are written with the `signoz-page-story` skill ([`.claude/skills/si
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
- **Plays live with the mocks.** A `play` is a named function exported from `stories/__story_mockdata__/<page>.play.ts`, which is collapsed for review like the rest of the AI-owned mocks. The story file only names it: `play: openEditDrawer`. A story file that still holds play bodies moves them there the first time you edit it.
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, row count, hidden header). A value the page supports must never fail the play.
- **A play passes for every control value.** Branch each check on the args it depends on (panel kind, data state, access, check state and revoked permissions, license, held-open tooltips, a count at 0, a view or tab that drops the target, a write state). A value the page supports must never fail the play. The `signoz-page-story` skill's **Plays** section lists the cases plays have missed.
- **Mocks answer every request a reachable state makes.** Selecting a row, opening a view or switching a tab that fetches more hits a handler, never the 501 catch-all.
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.

View File

@@ -106,6 +106,27 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
## Plays
CI runs each play once, with its story's own args. Anyone can flip a control in the panel, and nothing runs the play against that value, so a play that breaks on it goes unnoticed. Write each play so it cannot.
Before writing the clicks, list what has to be on screen for the play's target to exist, and return early when the args take it away. These are the cases plays have missed:
- The controls every story has. `State` on loading or error renders no rows and no filter suggestions. `Access`, `Permissions` and `Revoked` remove rows, row actions and gated buttons, and the `editor` and `viewer` presets grant telemetry reads only, so a page gated on its own resource shows them nothing. `Check state` on loading disables every gated control, and on error an `AuthZButton` stays enabled while an `AuthZGuardContent` set to fail closed hides what it wraps. `License` and `Banner` decide the plan, so a cloud-only form is gone on the others. `Hold tooltips open` shows a label a second time, inside its tooltip.
- A count at 0. The page can swap its whole list for an empty state, and the header, filters and column picker go with it.
- A view, filter or invalid query that leaves no rows, even when the count is above 0.
- A tab without the widget. A filter box that one tab has is not on the next.
- A second list beside the main one, such as saved views. At 0, loading or failed, its actions are gone.
- Text that depends on an arg. A disabled rule offers Enable, not Disable. A write that fails raises a different toast from one that lands, and one left loading raises none. Pick the expected text from the args.
- Rows that move. A view, a sort or pinning changes which row is locked, pinned or legacy, so find the row by what it is, not by its index.
Make those checks with the page's own code instead of restating a condition. Build the rows with the builder the handler uses and the view's own filter, and check a permission with the page's own check through `accessFor(...).allows(...)`, or the role against the constant the page gates on, such as `componentPermission.action`. Export the checks from the mocks module, next to the handlers, so a handler change moves them too. The alert rules page has `listedRules`, `hasRowMenu`, `showsNoRulesPage` and `listsSavedViews`; the dashboards list has `listedDashboards`, `canList`, `canCreate` and `canUpdate`.
Two habits cover the rest:
- Use `findAllBy*` and take the first whenever the text can appear twice, such as in a tooltip or a popover title.
- Wrap `toBeVisible` in `waitFor` for anything that fades in. A tooltip or toast found the moment it mounts is not visible yet.
## Rules
- **Default is the loaded page.** `export const Default: Story = {}` with no args, every widget carrying data. Empty, loading and failed are variants or control values, never the default.
@@ -116,7 +137,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`, play functions in `stories/__story_mockdata__/<page>.play.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. The play is a named function exported from `__story_mockdata__/<page>.play.ts` and the story file only names it (`play: openEditDrawer`), so the reviewer reads states, not clicks; a story file that still holds play bodies moves them there the first time you edit it. It branches every check on the args it depends on: a control value the page supports never fails it. See **Plays**. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
```ts
@@ -143,7 +164,7 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
- [ ] every control flipped once, its effect seen on screen
- [ ] every play passes with each control value its story exposes, and the story file holds no play body
- [ ] every play passes with each control value its story exposes, checked against the cases in **Plays**, and the story file holds no play body
- [ ] every request a reachable state makes (a selected row, an opened view, a switched tab) has a handler
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
- [ ] no navigation overlay on mount

View File

@@ -67,7 +67,7 @@ services:
timeout: 5s
retries: 3
telemetrystore-migrator:
image: signoz/signoz-otel-collector:v0.144.6
image: signoz/signoz-otel-collector:v0.144.15
container_name: telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -1,6 +1,6 @@
services:
signoz-otel-collector:
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.144.15
container_name: signoz-otel-collector-dev
entrypoint:
- /bin/sh

View File

@@ -18,25 +18,25 @@ env:
jobs:
prepare:
runs-on: ubuntu-latest
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
if: ${{ github.event_name == 'push' || startsWith(github.event.label.name, 'staging:') }}
outputs:
version: ${{ steps.build-info.outputs.version }}
hash: ${{ steps.build-info.outputs.hash }}
time: ${{ steps.build-info.outputs.time }}
branch: ${{ steps.build-info.outputs.branch }}
deployment: ${{ steps.build-info.outputs.deployment }}
path: ${{ steps.build-info.outputs.path }}
steps:
- name: self-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: token
name: github-token-gen
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.PRIMUS_APP_ID }}
private-key: ${{ secrets.PRIMUS_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: primus-checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: signoz/primus
ref: main
@@ -44,22 +44,29 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: build-info
id: build-info
env:
LABEL: ${{ github.event.label.name }}
run: |
echo "version=$($MAKE info-version)" >> $GITHUB_OUTPUT
echo "hash=$($MAKE info-commit-short)" >> $GITHUB_OUTPUT
echo "time=$($MAKE info-timestamp)" >> $GITHUB_OUTPUT
echo "branch=$($MAKE info-branch)" >> $GITHUB_OUTPUT
staging_label="${{ github.event.label.name }}"
if [[ "${staging_label}" == "staging:"* ]]; then
deployment=${staging_label#"staging:"}
elif [[ "${{ github.event.ref }}" == "refs/heads/main" ]]; then
deployment="staging"
# merges to main release the base every overlay inherits, a staging:<deployment> label releases that overlay
if [[ "${GITHUB_EVENT_NAME}" == push ]]; then
path="overlays/staging/*/signoz/base"
else
echo "error: not able to determine deployment - please verify the PR label or the branch"
# the deployment is spliced into the release path glob and the JSON payload
deployment="${LABEL#staging:}"
if [[ ! "${deployment}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo "::error::label ${LABEL} must be staging:<deployment>, where the deployment is lowercase letters, digits and dashes"
exit 1
fi
path="overlays/staging/*/signoz/overlays/${deployment}"
fi
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
{
echo "version=$($MAKE info-version)"
echo "hash=$($MAKE info-commit-short)"
echo "time=$($MAKE info-timestamp)"
echo "branch=$($MAKE info-branch)"
echo "path=${path}"
} >> "${GITHUB_OUTPUT}"
- name: create-dotenv
run: |
mkdir -p frontend
@@ -71,7 +78,7 @@ jobs:
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: frontend/.env
key: staging-dotenv-${{ github.sha }}
@@ -115,7 +122,6 @@ jobs:
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: gcp
staging:
if: ${{ contains(github.event.label.name, 'staging:') || github.event.ref == 'refs/heads/main' }}
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
secrets: inherit
needs: [prepare, go-build]
@@ -123,6 +129,6 @@ jobs:
PRIMUS_REF: main
GITHUB_ENVIRONMENT: staging
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
GITHUB_REPOSITORY_NAME: signoz.staging.cl.gcp.deployments
GITHUB_EVENT_NAME: release
GITHUB_EVENT_PAYLOAD: '{"path": "${{ needs.prepare.outputs.path }}", "image": "us-central1-docker.pkg.dev/signoz-registry/signoz/signoz", "tag": "${{ needs.prepare.outputs.version }}"}'

View File

@@ -88,7 +88,7 @@ jobs:
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-schema-migrator:v0.144.9 \
signoz/signoz-otel-collector:v0.144.16-rc.1 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python

View File

@@ -40,6 +40,7 @@ jobs:
suite:
- alertmanager
- alertmanagerrotation
- auditquerier
- basepath
- callbackauthn
- cloudintegrations
@@ -83,8 +84,8 @@ jobs:
- wal
clickhouse-version:
- 25.12.5
schema-migrator-version:
- v0.144.6
signoz-otel-collector-version:
- v0.144.16-rc.1
postgres-version:
- 15
if: |
@@ -155,4 +156,4 @@ jobs:
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--schema-migrator-version ${{matrix.schema-migrator-version}}
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}

View File

@@ -25613,7 +25613,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -26280,9 +26279,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Create alert rule
tags:
- rules
@@ -26327,9 +26340,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:delete
- tokenizer:
- EDITOR
- rule:delete
summary: Delete alert rule
tags:
- rules
@@ -26385,9 +26398,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get alert rule by ID
tags:
- rules
@@ -26569,7 +26582,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -27242,9 +27254,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Patch alert rule
tags:
- rules
@@ -27426,7 +27456,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -28087,9 +28116,27 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:update
- rule:attach
- rule:detach
- notification-channel:attach
- notification-channel:detach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Update alert rule
tags:
- rules
@@ -28156,9 +28203,25 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:read
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Clone alert rule
tags:
- rules
@@ -29045,7 +29108,6 @@ paths:
low (z-score {{$value}}).
summary: Span ingestion anomaly
condition:
algorithm: standard
compositeQuery:
panelType: graph
queries:
@@ -29712,9 +29774,23 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- EDITOR
- rule:create
- rule:attach
- notification-channel:attach
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Test alert rule
tags:
- rules
@@ -31809,9 +31885,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List alert rules (v3)
tags:
- rules

View File

@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` — PostgreSQL version (default: `15`)
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:

View File

@@ -70,3 +70,49 @@
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>
<script>
// Storybook has no microphone. Chromium's recognizer asks for one and for
// Google's speech service, and the `not-allowed` or `network` it reports makes
// the assistant hide its mic for the rest of the tab. This one starts
// listening at once and hears only what a story passes to `hear`.
(() => {
class StorySpeechRecognition extends EventTarget {
static listening = null;
lang = 'en-US';
continuous = false;
interimResults = false;
onstart = null;
onresult = null;
onerror = null;
onend = null;
start() {
StorySpeechRecognition.listening = this;
setTimeout(() => this.onstart?.());
}
stop() {
if (StorySpeechRecognition.listening === this) {
StorySpeechRecognition.listening = null;
}
setTimeout(() => this.onend?.());
}
abort() {
this.stop();
}
/** An interim result: the words so far, still listening. */
hear(transcript) {
const result = Object.assign([{ transcript, confidence: 1 }], {
isFinal: false,
});
this.onresult?.({ resultIndex: 0, results: [result] });
}
}
window.SpeechRecognition = StorySpeechRecognition;
window.webkitSpeechRecognition = StorySpeechRecognition;
})();
</script>

View File

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

View File

@@ -1,5 +1,37 @@
.panel:global(.floating-panel) {
box-shadow: var(--shadow-dialog);
.panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
border-left: 1px solid var(--l1-border);
background: var(--l1-background);
overflow: hidden;
position: relative;
}
.resizeHandle {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 10;
&::after {
content: '';
position: absolute;
top: 0;
left: 1px;
width: 2px;
height: 100%;
background: transparent;
transition: background 0.15s ease;
}
&:hover::after {
background: var(--accent-primary);
}
}
.header {

View File

@@ -1,14 +1,12 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
import { History, Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { BOTTOM_STRIP_HEIGHT } from 'container/BottomStrip';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import logEvent from 'api/common/logEvent';
@@ -24,22 +22,6 @@ import styles from './AIAssistantPanel.module.scss';
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
const PANEL_WIDTH = 420;
const PANEL_HEIGHT = 601;
const PANEL_MIN_WIDTH = 380;
const PANEL_MIN_HEIGHT = 500;
const PANEL_MARGIN = 8;
const PANEL_RESIZE_EDGES = {
top: true,
right: true,
bottom: true,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
export default function AIAssistantPanel(): JSX.Element | null {
const { pathname } = useAppLocation();
const [showHistory, setShowHistory] = useState(false);
@@ -90,6 +72,11 @@ export default function AIAssistantPanel(): JSX.Element | null {
setShowHistory(false);
}, []);
// ── Resize logic ──────────────────────────────────────────────────────────
const [panelWidth, setPanelWidth] = useState(380);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
useLayoutEffect(() => {
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
@@ -101,7 +88,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
if (shouldOffsetChatSupport) {
document.body.style.setProperty(
AI_ASSISTANT_PANEL_WIDTH_VAR,
`${PANEL_WIDTH}px`,
`${panelWidth}px`,
);
} else {
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
@@ -111,40 +98,46 @@ export default function AIAssistantPanel(): JSX.Element | null {
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
};
}, [isFullScreenPage, isOpen]);
}, [isFullScreenPage, isOpen, panelWidth]);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartWidth.current = panelWidth;
const onMouseMove = (ev: MouseEvent): void => {
// Panel is on the right; dragging left (lower clientX) increases width
const delta = dragStartX.current - ev.clientX;
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
setPanelWidth(next);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth],
);
if (!isOpen || isFullScreenPage) {
return null;
}
const panelHeight = Math.max(
PANEL_MIN_HEIGHT,
Math.min(
PANEL_HEIGHT,
window.innerHeight - BOTTOM_STRIP_HEIGHT - 2 * PANEL_MARGIN,
),
);
return (
<VariantContext.Provider value="panel">
<FloatingPanel
isOpen
className={styles.panel}
width={PANEL_WIDTH}
height={panelHeight}
minWidth={PANEL_MIN_WIDTH}
minHeight={PANEL_MIN_HEIGHT}
enableResizing={PANEL_RESIZE_EDGES}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN,
// Never above the viewport, where the header could not be dragged back.
y: Math.max(
0,
window.innerHeight - BOTTOM_STRIP_HEIGHT - PANEL_MARGIN - panelHeight,
),
}}
>
<div className={`${styles.header} floating-panel__drag-handle`}>
<div className={styles.panel} style={{ width: panelWidth }}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={`${styles.title} noz-wave`}>
<Noz size={18} />
<span>Noz</span>
@@ -177,19 +170,6 @@ export default function AIAssistantPanel(): JSX.Element | null {
</Button>
</Tooltip>
<Tooltip title="Minimize">
<Button
variant="ghost"
size="sm"
icon
color="secondary"
onClick={closeDrawer}
aria-label="Minimize panel"
>
<Minus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
disabledTooltip="Start a conversation first"
@@ -227,7 +207,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
<ConversationView conversationId={activeConversationId} />
)
)}
</FloatingPanel>
</div>
</VariantContext.Provider>
);
}

View File

@@ -34,9 +34,7 @@
border-top: 1px solid var(--l1-border);
&.compact {
padding: 0;
// Same colour as the panel's outline.
border-top-color: var(--l2-border);
padding: 8px;
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { useAppLocation } from 'lib/router/useAppLocation';
@@ -110,23 +110,27 @@ export default function ConversationView({
);
// Wall-clock timestamp of the current streaming start, used to compute
// `secondsSinceStart` on Cancel clicked and to keep the input's sweep in step
// across a remount. Cleared whenever streaming ends.
const [streamStartedAt, setStreamStartedAt] = useState<number | null>(null);
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
const streamStartedAtRef = useRef<number | null>(null);
useEffect(() => {
setStreamStartedAt((prev) => (isStreamingHere ? (prev ?? Date.now()) : null));
if (!isStreamingHere) {
streamStartedAtRef.current = null;
return;
}
if (streamStartedAtRef.current === null) {
streamStartedAtRef.current = Date.now();
}
}, [isStreamingHere]);
const handleCancel = useCallback(() => {
const startedAt = streamStartedAtRef.current;
void logEvent(AIAssistantEvents.CancelClicked, {
threadId: analyticsCtx.threadId,
secondsSinceStart:
streamStartedAt !== null
? Math.round((Date.now() - streamStartedAt) / 1000)
: null,
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
});
cancelStream(conversationId);
}, [cancelStream, conversationId, analyticsCtx.threadId, streamStartedAt]);
}, [cancelStream, conversationId, analyticsCtx.threadId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
@@ -205,8 +209,6 @@ export default function ConversationView({
disabled={inputDisabled}
disabledTooltip={inputDisabledTooltip}
isStreaming={isStreamingHere}
streamStartedAt={streamStartedAt}
showNozPeek={messages.length === 0 && !isStreamingHere}
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>

View File

@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { matchRoute } from 'lib/router/matchRoute';
import { navigate } from 'lib/router/navigation';
@@ -26,6 +28,7 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { UpdateTimeInterval } from 'store/actions';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
@@ -53,6 +56,7 @@ import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
@@ -279,6 +283,7 @@ interface ApplyFilterDeps {
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
setCustomTimeRange: (start: number, end: number) => void;
}
/**
@@ -344,18 +349,35 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
requestTypeFromActionQuery(action.query),
action.signal,
);
const timeRange = getTimeRangeFromActionQuery(action.query);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
if (timeRange) {
deps.setCustomTimeRange(timeRange.start, timeRange.end);
}
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(
normalized,
{
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
// relativeTime outranks startTime/endTime in the time picker, so drop it.
[QueryParams.relativeTime]: null,
}),
},
undefined,
undefined,
// New params like startTime would otherwise make safeNavigate replace the entry.
{ replace: false },
);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -368,6 +390,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
...(timeRange && {
[QueryParams.startTime]: timeRange.start,
[QueryParams.endTime]: timeRange.end,
}),
});
navigate(url);
}
@@ -402,6 +428,13 @@ export default function ActionsSection({
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
const dispatch = useDispatch();
const setCustomTimeRange = useCallback(
(start: number, end: number): void => {
dispatch(UpdateTimeInterval('custom', [start, end]));
},
[dispatch],
);
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
@@ -433,9 +466,16 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
});
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
}, [
actions,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
]);
if (actions.length === 0) {
return null;
@@ -568,6 +608,7 @@ export default function ActionsSection({
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
setCustomTimeRange,
});
break;
}

View File

@@ -9,6 +9,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { UpdateTimeInterval } from 'store/actions';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
@@ -24,7 +25,17 @@ jest.mock('api/common/logEvent', () => ({
default: jest.fn(),
}));
jest.mock('store/actions', () => {
const actual =
jest.requireActual<typeof import('store/actions')>('store/actions');
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
});
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
const UNDO_ACTION = {
kind: MessageActionKindDTO.undo,
@@ -38,13 +49,18 @@ function renderUndoChip(): HTMLElement {
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
function countByServiceAction(
label: string,
{ start, end }: { start?: number; end?: number } = {},
): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
start,
end,
compositeQuery: {
queries: [
{
@@ -62,10 +78,8 @@ function countByServiceAction(label: string): MessageActionDTO {
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
function currentParam(param: string): string | null {
return new URLSearchParams(getCurrentLocation().search).get(param);
}
describe('ActionsSection rollback chip', () => {
@@ -139,7 +153,34 @@ describe('ActionsSection apply_filter', () => {
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
expect(currentParam(QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TABLE),
);
});
it('opens the explorer on the time range that produced the answer', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[
countByServiceAction('Open with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-with-time"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
});
it('switches the open explorer to the Table view when applied on it', async () => {
@@ -167,6 +208,54 @@ describe('ActionsSection apply_filter', () => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
undefined,
undefined,
{ replace: false },
);
});
it('switches the open explorer to the answer time range', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[
countByServiceAction('Apply with time', {
start: START_MS,
end: END_MS,
}),
]}
messageId="message-on-page-with-time"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
[QueryParams.startTime]: START_MS,
[QueryParams.endTime]: END_MS,
[QueryParams.relativeTime]: null,
},
undefined,
undefined,
{ replace: false },
);
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
START_MS,
END_MS,
]);
});
});

View File

@@ -0,0 +1,25 @@
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { normalizeTimeToMs } from 'utils/timeUtils';
export interface ActionTimeRange {
start: number;
end: number;
}
function toTimestampMs(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
}
return normalizeTimeToMs(Math.trunc(value));
}
export function getTimeRangeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): ActionTimeRange | null {
const start = toTimestampMs(query?.start);
const end = toTimestampMs(query?.end);
if (start === null || end === null || end <= start) {
return null;
}
return { start, end };
}

View File

@@ -3,32 +3,24 @@
.input {
display: flex;
flex-direction: column;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-3);
box-shadow: var(--shadow-dialog);
gap: 10px;
background: var(--l1-background);
border-radius: var(--radius-2);
padding: 8px;
border: 1px solid var(--l1-border);
position: relative;
// The panel already frames it.
&.compact {
border: none;
border-radius: 0;
box-shadow: none;
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 0;
}
.contextTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 0;
}
.contextTag {
@@ -99,99 +91,86 @@
white-space: nowrap;
}
.composer {
position: relative;
padding: 9px 16px;
&:focus-within {
animation: selectionAccent 2s step-end infinite;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
@keyframes selectionAccent {
0%,
50% {
--selection-accent: var(--accent-primary);
}
50.001%,
100% {
--selection-accent: var(--accent-sakura);
.composer {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 4px;
transition:
border-color 0.15s,
box-shadow 0.15s;
// Scope the focus ring to the textarea row only — the surrounding
// chrome (context chips, "Add Context", mic, send) sits outside this
// element and stays unaffected when the cursor enters the textarea.
&:focus-within {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--accent-primary), transparent 70%);
}
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 4px 10px 8px;
justify-content: space-between;
gap: 8px;
}
.actions {
.leftActions,
.rightActions {
display: flex;
align-items: center;
gap: 8px;
--button-height: 28px;
--button-width: 28px;
--button-max-width: 28px;
--button-border-radius: 9999px;
}
// Send stays a solid grey until there is something to send.
[data-color='primary'][aria-disabled='true'] {
--button-primary-background-color: var(--l3-background);
--button-primary-solid-foreground: var(--l2-foreground);
--button-disabled-opacity: 1;
}
.attachBtn {
color: var(--l3-foreground);
}
.contextBtn {
flex-shrink: 0;
gap: 6px;
border-style: dashed !important;
padding-inline: 12px !important;
}
.textarea {
display: block;
width: 100%;
padding: 0;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.005em;
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
font-family: inherit;
// ComposerCaret draws the caret.
caret-color: transparent;
@include scrollbar(0.2rem);
&::placeholder {
color: var(--l2-foreground);
color: var(--l3-foreground);
opacity: 0.6;
}
&::selection {
background: var(--selection-accent, var(--accent-primary));
color: var(--l1-background);
}
&:disabled {
opacity: 0.2;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
caret-color: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.composer:focus-within {
animation: none;
}
}
.charWarning {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 10px;
width: 100%;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
@@ -201,9 +180,24 @@
border-radius: var(--radius-2);
}
.sendBtn {
flex-shrink: 0;
border-radius: var(--radius-2);
&.stop {
background: var(--accent-cherry) !important;
border-color: var(--accent-cherry) !important;
&:hover {
opacity: 0.85;
}
}
}
.contextPopover {
width: 480px !important;
max-width: min(92vw, 480px);
margin-left: 16px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
@@ -344,3 +338,75 @@
overflow-wrap: anywhere;
text-align: center;
}
.micRecording {
display: flex;
align-items: center;
background: var(--l2-background);
border-radius: 999px;
padding: 2px;
gap: 0;
flex-shrink: 0;
overflow: hidden;
--button-icon-size: 12px;
> :last-child {
--button-icon-size: 9px;
--button-variant-ghost-color: var(--accent-cherry);
--button-variant-ghost-hover-color: var(--accent-cherry);
}
}
.micWaves {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 20px;
padding: 0 6px;
span {
display: block;
width: 2px;
border-radius: 2px;
background: var(--l1-foreground);
animation: voiceWave 0.9s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.1s;
}
&:nth-child(3) {
animation-delay: 0.2s;
}
&:nth-child(4) {
animation-delay: 0.3s;
}
&:nth-child(5) {
animation-delay: 0.4s;
}
&:nth-child(6) {
animation-delay: 0.3s;
}
&:nth-child(7) {
animation-delay: 0.2s;
}
&:nth-child(8) {
animation-delay: 0.1s;
}
}
}
@keyframes voiceWave {
0%,
100% {
height: 2px;
opacity: 0.4;
}
50% {
height: 12px;
opacity: 1;
}
}

View File

@@ -11,8 +11,11 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { toast } from '@signozhq/ui/sonner';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import getSessionStorage from 'api/browser/sessionstorage/get';
import setSessionStorage from 'api/browser/sessionstorage/set';
import {
getListDashboardsForUserV2QueryKey,
useListDashboardsForUserV2,
@@ -28,29 +31,34 @@ import type {
ListRulesV3200,
ListRulesV3Params,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
// eslint-disable-next-line
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
AIAssistantEvents,
VoiceInputSource,
getBrowserInfo,
} from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { useVariant } from '../../VariantContext';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
ArrowUp,
AtSign,
Mic,
Plus,
Search,
Send,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
import ComposerCaret from './ComposerCaret/ComposerCaret';
import ContextPickerEmptyState from './ContextPickerEmptyState';
import EdgeSweep from './EdgeSweep/EdgeSweep';
import NozPeek from './NozPeek/NozPeek';
import {
CONTEXT_CATEGORIES,
CONTEXT_CATEGORY_ICONS,
@@ -67,10 +75,6 @@ interface ChatInputProps {
disabled?: boolean;
disabledTooltip?: string;
isStreaming?: boolean;
/** When the current stream began; drives the top-edge sweep. */
streamStartedAt?: number | null;
/** Noz peeks over the top edge; meant for an empty conversation. */
showNozPeek?: boolean;
/**
* URL-derived `source: 'auto'` contexts representing the page the user is
* currently looking at. Rendered as chips alongside the user's `@`-mention
@@ -167,6 +171,8 @@ const WARNING_THRESHOLD = 15000;
// squeezed in tighter container variants (e.g. the floating panel).
const TEXTAREA_MAX_HEIGHT_PX = 200;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
/**
* The picker filters client-side, so it pulls one large page instead of
* paginating. Shared with `getQueryData` below — the params are part of the
@@ -237,15 +243,12 @@ export default function ChatInput({
disabled,
disabledTooltip,
isStreaming = false,
streamStartedAt = null,
showNozPeek = false,
autoContexts,
onDismissAutoContext,
}: ChatInputProps): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const isCompact = useVariant() === 'panel';
const [text, setText] = useState('');
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
const [selectedContexts, setSelectedContexts] = useState<
@@ -285,6 +288,8 @@ export default function ChatInput({
endTime: now,
};
});
// Stores the already-committed final text so interim results don't overwrite it
const committedTextRef = useRef('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRootRef = useRef<HTMLDivElement>(null);
@@ -338,6 +343,7 @@ export default function ChatInput({
const next =
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
}
},
@@ -350,6 +356,7 @@ export default function ChatInput({
(prompt: string) => {
const next = capText(prompt);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
setPickerSearchQuery('');
setIsContextPickerOpen(false);
@@ -490,6 +497,7 @@ export default function ChatInput({
contexts.length > 0 ? contexts : undefined,
);
setText('');
committedTextRef.current = '';
setPendingFiles([]);
setSelectedContexts([]);
textareaRef.current?.focus();
@@ -524,6 +532,209 @@ export default function ChatInput({
[],
);
// ── Voice input ────────────────────────────────────────────────────────────
const analyticsCtx = useAIAssistantAnalyticsContext();
// Captured at the start of a voice session, consumed when it ends.
// Tracks both the trigger (button vs. PTT shortcut) and the wall-clock
// start time so we can attribute `durationMs` on the Voice input used
// event regardless of which control ended the session.
const voiceStartedAtRef = useRef<number | null>(null);
const voiceSourceRef = useRef<VoiceInputSource | null>(null);
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
// so we hide the mic button for the rest of the tab session — silent
// retries don't help, and Chromium derivatives without the Google Speech
// API key always fail with `network` no matter how many times the user
// clicks. Persisted to sessionStorage so a page reload doesn't surface the
// button again (closing the tab still resets, in case the user fixed
// permissions or switched browsers).
const [voiceUnavailable, setVoiceUnavailable] = useState(
() => getSessionStorage(VOICE_UNAVAILABLE_KEY) === 'true',
);
const markVoiceUnavailable = useCallback((): void => {
setVoiceUnavailable(true);
setSessionStorage(VOICE_UNAVAILABLE_KEY, 'true');
}, []);
const {
isListening,
isSupported,
permission: micPermission,
start,
discard,
} = useSpeechRecognition({
onTranscript: (transcriptText, isFinal) => {
if (isFinal) {
// Commit: append to whatever the user has already typed
const separator = committedTextRef.current ? ' ' : '';
const next = capText(committedTextRef.current + separator + transcriptText);
committedTextRef.current = next;
setText(next);
} else {
// Interim: live preview appended to committed text, not yet persisted
const separator = committedTextRef.current ? ' ' : '';
setText(capText(committedTextRef.current + separator + transcriptText));
}
},
onError: (error) => {
// Guard against double-fire: Chrome can fire `onerror` more than
// once per session when `continuous = true` (it retries internally
// before giving up). Only fire the analytics event for the first
// error in a given session — voiceSourceRef being null means we've
// already handled it.
const source = voiceSourceRef.current;
if (source === null) {
return;
}
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
void logEvent(AIAssistantEvents.VoiceInputFailed, {
...analyticsCtx,
...getBrowserInfo(),
source,
errorType: error,
});
if (error === 'network') {
markVoiceUnavailable();
toast.error('Voice input unavailable in this browser', {
description:
'This browser cannot reach the speech recognition service. Try Google Chrome or Microsoft Edge.',
});
} else if (error === 'not-allowed') {
markVoiceUnavailable();
toast.error('Microphone access denied', {
description:
'Grant microphone permission in your browser settings to use voice input.',
});
} else if (error === 'not-supported') {
markVoiceUnavailable();
toast.error('Voice input is not supported in this browser.');
}
// `no-speech` is benign (just silence) — don't toast or hide.
},
});
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
const startVoiceInput = useCallback(
(source: VoiceInputSource) => {
// Defense in depth: the button is hidden when `voiceUnavailable` is
// true, but the PTT shortcut listener can still call us. Bailing here
// keeps a single source of truth and prevents repeat `Voice input
// failed` events in the same session.
if (voiceUnavailable) {
return;
}
voiceStartedAtRef.current = Date.now();
voiceSourceRef.current = source;
start();
},
[start, voiceUnavailable],
);
const fireVoiceInputEvent = useCallback(
(outcome: 'sent' | 'discarded') => {
const startedAt = voiceStartedAtRef.current;
const source = voiceSourceRef.current;
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
if (startedAt === null || source === null) {
return;
}
void logEvent(AIAssistantEvents.VoiceInputUsed, {
...analyticsCtx,
...getBrowserInfo(),
source,
outcome,
durationMs: Date.now() - startedAt,
});
},
[analyticsCtx],
);
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
// Promote the displayed text (interim included) to committed so handleSend sees it.
committedTextRef.current = capText(text);
// Stop recognition without triggering onTranscript again (would double-append).
discard();
fireVoiceInputEvent('sent');
await handleSend();
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
fireVoiceInputEvent('discarded');
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard, fireVoiceInputEvent]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
// triggered PTT in a ref so a late-released modifier (Cmd/Shift) doesn't
// accidentally stop the session. Auto-repeat is suppressed via a
// "session active" ref so a held key only calls `start()` once.
const pttActiveRef = useRef(false);
useEffect(() => {
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const isComboKey =
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
(e.code === 'Space' || e.key === ' ');
if (!isComboKey || disabled || isStreaming) {
return;
}
e.preventDefault();
if (pttActiveRef.current) {
return; // ignore auto-repeat
}
pttActiveRef.current = true;
startVoiceInput(VoiceInputSource.Shortcut);
};
const handleKeyUp = (e: KeyboardEvent): void => {
if (!pttActiveRef.current) {
return;
}
// End on the *first* released key in the combo. macOS browsers
// frequently swallow keyup of regular keys (incl. Space) while
// Cmd is held, so we can't rely on Space-up alone — releasing
// Cmd/Ctrl/Shift must also stop the session.
const isComboKey =
e.code === 'Space' ||
e.key === ' ' ||
e.key === 'Meta' ||
e.key === 'Control' ||
e.key === 'Shift';
if (!isComboKey) {
return;
}
pttActiveRef.current = false;
e.preventDefault();
void handleStopAndSend();
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
isSupported,
micPermission,
voiceUnavailable,
disabled,
isStreaming,
startVoiceInput,
handleStopAndSend,
]);
// Each list hook fetches only when its picker tab is actively shown,
// AND treats already-cached data as never stale (`staleTime: Infinity`)
// so an open with a populated cache doesn't trigger a background
@@ -689,12 +900,7 @@ export default function ChatInput({
const showTextWarning = currentLength >= WARNING_THRESHOLD;
return (
<div
className={cx(styles.input, { [styles.compact]: isCompact })}
ref={inputRootRef}
>
<EdgeSweep startedAt={streamStartedAt} />
{showNozPeek && <NozPeek />}
<div className={styles.input} ref={inputRootRef}>
{pendingFiles.length > 0 && (
<div className={styles.attachments}>
{pendingFiles.map((f) => (
@@ -777,19 +983,20 @@ export default function ChatInput({
<textarea
ref={textareaRef}
className={styles.textarea}
placeholder="Ask Noz…"
placeholder="Ask anything… (Shift+Enter for new line)"
value={text}
onChange={(e): void => {
const next = capText(e.target.value);
setText(next);
// Keep committed text in sync when the user edits manually
committedTextRef.current = next;
syncContextPickerFromText(next, e.target.selectionStart ?? next.length);
}}
onKeyDown={handleKeyDown}
disabled={disabled}
maxLength={MAX_INPUT_LENGTH}
rows={2}
rows={3}
/>
<ComposerCaret textareaRef={textareaRef} value={text} />
</div>
{showTextWarning && (
<div className={styles.charWarning} role="status">
@@ -802,7 +1009,7 @@ export default function ChatInput({
)}
<div className={styles.footer}>
<div className={styles.actions}>
<div className={styles.leftActions}>
<Popover
open={isContextPickerOpen}
onOpenChange={(open): void => {
@@ -813,25 +1020,22 @@ export default function ChatInput({
}
}}
>
<Tooltip title="Add context">
<PopoverTrigger asChild>
<Button
disabledTooltip={disabledTooltip}
variant="ghost"
color="secondary"
size="sm"
icon
disabled={disabled}
onClick={(): void => {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}}
aria-label="Add context"
>
<AtSign size={14} />
</Button>
</PopoverTrigger>
</Tooltip>
<PopoverTrigger asChild>
<Button
disabledTooltip={disabledTooltip}
variant="solid"
color="secondary"
size="md"
disabled={disabled}
onClick={(): void => {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}}
prefix={<Plus size={10} />}
>
Add Context
</Button>
</PopoverTrigger>
<PopoverContent
className={styles.contextPopover}
side="top"
@@ -960,18 +1164,82 @@ export default function ChatInput({
</div>
</PopoverContent>
</Popover>
</div>
<div className={styles.rightActions}>
{showMic &&
(isListening ? (
<div
className={styles.micRecording}
role="status"
aria-live="polite"
aria-label="Recording voice input"
>
<Tooltip title="Discard recording">
<Button
type="button"
variant="ghost"
size="md"
icon
color="secondary"
onClick={handleDiscard}
aria-label="Discard recording"
>
<X size={12} />
</Button>
</Tooltip>
<span className={styles.micWaves} aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</span>
<Tooltip title="Stop and send">
<Button
type="button"
variant="ghost"
size="md"
icon
color="secondary"
onClick={handleStopAndSend}
aria-label="Stop and send"
>
<Square size={9} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
</div>
) : (
<Tooltip title="Voice input">
<Button
disabledTooltip={disabledTooltip}
color="secondary"
variant="ghost"
size="md"
icon
onClick={(): void => startVoiceInput(VoiceInputSource.Button)}
disabled={disabled}
aria-label="Start voice input"
>
<Mic size={14} />
</Button>
</Tooltip>
))}
{isStreaming && onCancel ? (
<Tooltip title="Stop generating">
<Button
variant="solid"
size="sm"
size="md"
icon
color="danger"
onClick={onCancel}
aria-label="Stop generating"
>
<Square size={14} fill="currentColor" />
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
) : (
@@ -979,14 +1247,14 @@ export default function ChatInput({
<Button
disabledTooltip={disabled ? disabledTooltip : 'Type a message first'}
variant="solid"
size="sm"
size="md"
icon
color="primary"
onClick={handleSend}
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<ArrowUp size={14} />
<Send size={14} />
</Button>
</Tooltip>
)}

View File

@@ -1,67 +0,0 @@
.caret {
position: absolute;
top: 0;
left: 0;
width: 2px;
// Overhangs the glyph box by 1px each way, like the native caret.
margin: -1px 0 0 -1px;
padding: 1px 0;
border-radius: 1px;
pointer-events: none;
background: var(--accent-primary);
transition: transform 0.05s linear;
animation:
caretBlink 1s step-end infinite,
caretAccent 2s step-end infinite;
// Solid while keys are going, then blinks again from blue.
&.isTyping {
animation: none;
}
}
.trail {
position: absolute;
pointer-events: none;
opacity: 0.4;
background: var(--accent-primary);
animation: trailFade 0.3s ease-out 0.4s forwards;
&.isFaint {
opacity: 0.2;
}
}
@keyframes caretBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes caretAccent {
0%,
50% {
background: var(--accent-primary);
}
50.001%,
100% {
background: var(--accent-sakura);
}
}
@keyframes trailFade {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.caret,
.trail {
display: none;
}
}

View File

@@ -1,50 +0,0 @@
import { RefObject } from 'react';
import cx from 'classnames';
import { useComposerCaret } from './useComposerCaret';
import styles from './ComposerCaret.module.scss';
interface ComposerCaretProps {
textareaRef: RefObject<HTMLTextAreaElement>;
value: string;
}
export default function ComposerCaret({
textareaRef,
value,
}: ComposerCaretProps): JSX.Element {
const { caret, isTyping, trails, removeTrail } = useComposerCaret(
textareaRef,
value,
);
return (
<>
{trails.map((trail) => (
<span
key={trail.id}
className={cx(styles.trail, { [styles.isFaint]: trail.faint })}
style={{
left: trail.left,
top: trail.top,
width: trail.width,
height: trail.height,
}}
onAnimationEnd={(): void => removeTrail(trail.id)}
aria-hidden
/>
))}
{caret && (
<span
className={cx(styles.caret, { [styles.isTyping]: isTyping })}
style={{
transform: `translate(${caret.left}px, ${caret.top}px)`,
height: caret.height,
}}
aria-hidden
/>
)}
</>
);
}

View File

@@ -1,73 +0,0 @@
import type { CaretBox } from './types';
// Everything that decides where a line wraps.
const MIRRORED_STYLES = [
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-style',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'font-stretch',
'font-feature-settings',
'font-variation-settings',
'font-kerning',
'line-height',
'letter-spacing',
'word-spacing',
'text-align',
'text-indent',
'text-transform',
'tab-size',
'white-space',
'word-break',
'overflow-wrap',
];
// Caret box in the textarea's border box, measured on an off-screen copy of its text.
export function getCaretCoordinates(
textarea: HTMLTextAreaElement,
position: number,
): CaretBox {
const computed = getComputedStyle(textarea);
const borderTop = parseFloat(computed.borderTopWidth) || 0;
const borderLeft = parseFloat(computed.borderLeftWidth) || 0;
const borderRight = parseFloat(computed.borderRightWidth) || 0;
const mirror = document.createElement('div');
MIRRORED_STYLES.forEach((name) => {
mirror.style.setProperty(name, computed.getPropertyValue(name));
});
Object.assign(mirror.style, {
position: 'absolute',
top: '0',
left: '-9999px',
visibility: 'hidden',
overflow: 'hidden',
boxSizing: 'border-box',
width: `${textarea.clientWidth + borderLeft + borderRight}px`,
});
mirror.textContent = textarea.value.slice(0, position);
const marker = document.createElement('span');
marker.textContent = textarea.value.slice(position) || '.';
mirror.appendChild(marker);
document.body.appendChild(mirror);
const box = {
left: marker.offsetLeft + borderLeft,
top: marker.offsetTop + borderTop,
height:
marker.getClientRects()[0]?.height || parseFloat(computed.lineHeight) || 0,
};
mirror.remove();
return box;
}

View File

@@ -1,11 +0,0 @@
export interface CaretBox {
left: number;
top: number;
height: number;
}
export interface CaretTrail extends CaretBox {
id: number;
width: number;
faint: boolean;
}

View File

@@ -1,159 +0,0 @@
import {
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { getCaretCoordinates } from './getCaretCoordinates';
import type { CaretBox, CaretTrail } from './types';
interface UseComposerCaretResult {
caret: CaretBox | null;
isTyping: boolean;
trails: CaretTrail[];
removeTrail: (id: number) => void;
}
const TYPING_IDLE_MS = 800;
const MAX_TRAILS = 4;
const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
const EDIT_KEYS = new Set(['Backspace', 'Delete']);
export function useComposerCaret(
textareaRef: RefObject<HTMLTextAreaElement>,
value: string,
): UseComposerCaretResult {
const [caret, setCaret] = useState<CaretBox | null>(null);
const [isTyping, setIsTyping] = useState(false);
const [trails, setTrails] = useState<CaretTrail[]>([]);
const lastCaretRef = useRef<CaretBox | null>(null);
const trailIdRef = useRef(0);
const typingTimerRef = useRef<number>();
const measure = useCallback((): CaretBox | null => {
const el = textareaRef.current;
if (
!el ||
document.activeElement !== el ||
el.selectionStart !== el.selectionEnd
) {
return null;
}
const box = getCaretCoordinates(el, el.selectionEnd);
const top = el.offsetTop + box.top - el.scrollTop;
const isInView =
top >= el.offsetTop && top + box.height <= el.offsetTop + el.offsetHeight;
if (!isInView) {
return null;
}
return {
left: el.offsetLeft + box.left - el.scrollLeft,
top,
height: box.height,
};
}, [textareaRef]);
const update = useCallback((): void => {
const next = measure();
lastCaretRef.current = next;
setCaret(next);
}, [measure]);
// Leaves a fading streak over a sideways jump on the same line.
const addTrail = useCallback((from: CaretBox, to: CaretBox): void => {
if (Math.abs(to.top - from.top) >= 5 || Math.abs(to.left - from.left) <= 2) {
return;
}
trailIdRef.current += 1;
const id = trailIdRef.current;
setTrails((prev) =>
[
...prev,
{
id,
left: Math.min(from.left, to.left),
top: to.top,
width: Math.abs(to.left - from.left),
height: to.height,
faint: prev.length === 0,
},
].slice(-MAX_TRAILS),
);
}, []);
const removeTrail = useCallback((id: number): void => {
setTrails((prev) => prev.filter((trail) => trail.id !== id));
}, []);
useEffect(() => {
const el = textareaRef.current;
if (!el) {
return undefined;
}
let frame = 0;
const onKeyDown = (event: KeyboardEvent): void => {
const isArrow = ARROW_KEYS.has(event.key);
if (!isArrow && !EDIT_KEYS.has(event.key) && event.key.length !== 1) {
return;
}
setIsTyping(true);
window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(
() => setIsTyping(false),
TYPING_IDLE_MS,
);
if (isArrow) {
const from = lastCaretRef.current;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const to = measure();
if (from && to) {
addTrail(from, to);
}
lastCaretRef.current = to;
setCaret(to);
});
}
};
const onSelectionChange = (): void => {
if (document.activeElement === el) {
update();
}
};
const hide = (): void => {
lastCaretRef.current = null;
setCaret(null);
};
const observer = new ResizeObserver(() => update());
el.addEventListener('keydown', onKeyDown);
el.addEventListener('focus', update);
el.addEventListener('blur', hide);
el.addEventListener('scroll', update);
document.addEventListener('selectionchange', onSelectionChange);
observer.observe(el);
update();
return (): void => {
el.removeEventListener('keydown', onKeyDown);
el.removeEventListener('focus', update);
el.removeEventListener('blur', hide);
el.removeEventListener('scroll', update);
document.removeEventListener('selectionchange', onSelectionChange);
observer.disconnect();
cancelAnimationFrame(frame);
window.clearTimeout(typingTimerRef.current);
};
}, [textareaRef, measure, update, addTrail]);
// Text set from code (prefill, @-mentions, send) moves the caret without a key.
useLayoutEffect(() => {
update();
}, [value, update]);
return { caret, isTyping, trails, removeTrail };
}

View File

@@ -1,76 +0,0 @@
// Offsets are from the input's padding box; its top edge, border or separator, sits at -1px.
.edgeSweep {
position: absolute;
top: -11px;
left: 0;
right: 0;
height: 20px;
overflow: hidden;
opacity: 0;
z-index: 2;
pointer-events: none;
transition: opacity 0.3s ease;
mask-image: linear-gradient(
to right,
transparent 11px,
var(--l1-background) 60px,
var(--l1-background) calc(100% - 60px),
transparent calc(100% - 11px)
);
&.isVisible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
display: none;
}
}
.line {
position: absolute;
top: 9px;
left: 0;
width: 40%;
height: 2px;
animation: edgeSweep 2s ease-in-out infinite alternate;
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
background: linear-gradient(
to right,
transparent,
var(--accent-primary) 20%,
var(--accent-sakura) 40%,
var(--accent-primary) 60%,
var(--accent-sakura) 80%,
transparent
);
}
&::before {
top: 1px;
height: 1px;
}
&::after {
top: 0;
height: 2px;
filter: blur(4px);
opacity: 0.7;
}
}
@keyframes edgeSweep {
from {
transform: translateX(-50%);
}
to {
transform: translateX(200%);
}
}

View File

@@ -1,46 +0,0 @@
import { useState } from 'react';
import cx from 'classnames';
import styles from './EdgeSweep.module.scss';
interface EdgeSweepProps {
/** When the current stream began; null while idle. */
startedAt: number | null;
}
interface SweepRun {
startedAt: number;
elapsedMs: number;
}
/** Gradient sweeping the input's top edge while Noz replies. */
export default function EdgeSweep({ startedAt }: EdgeSweepProps): JSX.Element {
// Outlives the stream so the line keeps moving while it fades out.
const [run, setRun] = useState<SweepRun | null>(() =>
startedAt === null ? null : { startedAt, elapsedMs: Date.now() - startedAt },
);
// A new stream restarts from the left; a remount mid-stream picks up where it was.
if (startedAt !== null && startedAt !== run?.startedAt) {
setRun({ startedAt, elapsedMs: Date.now() - startedAt });
}
return (
<div
className={cx(styles.edgeSweep, { [styles.isVisible]: startedAt !== null })}
onTransitionEnd={(): void => {
if (startedAt === null) {
setRun(null);
}
}}
aria-hidden
>
{run && (
<div
key={run.startedAt}
className={styles.line}
style={{ animationDelay: `${-run.elapsedMs}ms` }}
/>
)}
</div>
);
}

View File

@@ -1,109 +0,0 @@
// Offsets are from the input's padding box; its top edge, border or separator, sits at -1px.
.body {
position: absolute;
top: -29px;
right: 6px;
width: 40px;
height: 40px;
pointer-events: none;
// Hides what is below the top edge, as if Noz stood behind the input.
clip-path: inset(-50% -50% 12px -50%);
}
.bodySvg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
animation: bodyPeek 0.7s cubic-bezier(0.34, 1.45, 0.5, 1) 0.95s both;
}
.head {
transform-origin: 12px 16px;
animation: headBob 0.5s ease-in-out 1.05s 2;
}
.pupil {
transition: transform 0.13s ease-out;
}
.hands {
position: absolute;
top: -5px;
right: 9px;
width: 34px;
height: 7px;
pointer-events: none;
// Behind the top edge on the way up, in front of it on the way down.
animation: handsClip 0.7s 0.6s both;
}
.handsSvg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
animation: handsPeek 0.7s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
}
@keyframes bodyPeek {
from {
transform: translateY(34px);
}
to {
transform: translateY(0);
}
}
@keyframes headBob {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-1px) rotate(-6deg);
}
}
@keyframes handsPeek {
0% {
transform: translateY(16px);
opacity: 0;
}
20% {
opacity: 1;
}
50%,
51% {
transform: translateY(-7px);
}
100% {
transform: translateY(2px);
opacity: 1;
}
}
@keyframes handsClip {
0%,
50% {
clip-path: inset(-20px -20px 3px -20px);
}
51%,
100% {
clip-path: inset(-20px);
}
}
@media (prefers-reduced-motion: reduce) {
.bodySvg,
.head,
.hands,
.handsSvg {
animation: none;
}
.handsSvg {
transform: translateY(2px);
}
}

View File

@@ -1,70 +0,0 @@
import { useRef } from 'react';
import { useNozPupilTracking } from './useNozPupilTracking';
import styles from './NozPeek.module.scss';
/** Noz climbs over the input's top edge, hands first, then watches the cursor. */
export default function NozPeek(): JSX.Element {
const eyeRef = useRef<SVGCircleElement>(null);
const pupilRef = useRef<SVGGElement>(null);
useNozPupilTracking(eyeRef, pupilRef);
return (
<>
<div className={styles.body} aria-hidden>
<svg
className={styles.bodySvg}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="4.35938"
y="8.49908"
width="15.4569"
height="11.978"
rx="1.76147"
fill="#E5484D"
/>
<g className={styles.head}>
<circle
ref={eyeRef}
cx="12.0217"
cy="14.4881"
r="3.87523"
fill="#F5F5F5"
/>
<g ref={pupilRef} className={styles.pupil}>
<path
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
fill="#0A0C10"
/>
</g>
<path
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
fill="#4E74F8"
/>
</g>
</svg>
</div>
<div className={styles.hands} aria-hidden>
<svg
className={styles.handsSvg}
viewBox="0 0 34 7"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="3.52294" height="6.16514" rx="1.46789" fill="#E5484D" />
<rect
x="30.4775"
width="3.52294"
height="6.16514"
rx="1.46789"
fill="#E5484D"
/>
</svg>
</div>
</>
);
}

View File

@@ -1,46 +0,0 @@
import { RefObject, useEffect } from 'react';
// Noz has settled on the edge by now.
const SETTLE_MS = 1900;
// Furthest the pupil moves, in SVG units.
const MAX_OFFSET = 1.25;
// Cursor distance, in px, at which the pupil reaches MAX_OFFSET.
const FULL_REACH_PX = 160;
export function useNozPupilTracking(
eyeRef: RefObject<SVGCircleElement>,
pupilRef: RefObject<SVGGElement>,
): void {
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
let isSettled = false;
const timer = window.setTimeout(
() => {
isSettled = true;
},
prefersReducedMotion ? 0 : SETTLE_MS,
);
const onMouseMove = (event: MouseEvent): void => {
const eye = eyeRef.current;
const pupil = pupilRef.current;
if (!isSettled || !eye || !pupil) {
return;
}
const rect = eye.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy) || 1;
const reach = Math.min(1, distance / FULL_REACH_PX) * MAX_OFFSET;
pupil.style.transform = `translate(${((dx / distance) * reach).toFixed(3)}px, ${((dy / distance) * reach).toFixed(3)}px)`;
};
window.addEventListener('mousemove', onMouseMove);
return (): void => {
window.clearTimeout(timer);
window.removeEventListener('mousemove', onMouseMove);
};
}, [eyeRef, pupilRef]);
}

View File

@@ -23,6 +23,24 @@ jest.mock('hooks/useQueryService', () => ({
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';

View File

@@ -3,7 +3,7 @@ import { render, screen, userEvent } from 'tests/test-utils';
import ChatInput from '../ChatInput';
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask Noz/i) as HTMLTextAreaElement;
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — @ mention trigger', () => {

View File

@@ -29,6 +29,26 @@ jest.mock('hooks/useQueryService', () => ({
}),
}));
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
// context providers, so stub them out.
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
@@ -38,7 +58,7 @@ function renderChatInput(): void {
}
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask Noz/i) as HTMLTextAreaElement;
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — empty-state CTA prefill flow', () => {

View File

@@ -4,6 +4,58 @@
* requested, Limit hit) are not declared here — they fire from the AI service.
*/
export interface BrowserInfo {
browserName: string;
browserVersion: string;
}
type NavigatorWithBrandHints = Navigator & {
userAgentData?: { brands: { brand: string; version: string }[] };
brave?: { isBrave: () => Promise<boolean> };
};
/**
* We mainly need to distinguish Chrome / Edge (Speech API works) from Chromium
* derivatives (no Google API key → voice fails with `network`). UA sniffing is
* the source of truth for derivative identification; `userAgentData` is used
* only as a fast happy path for Chrome / Edge. Brave needs its own probe — it
* advertises Chrome in both UA and brand hints.
*/
export function getBrowserInfo(): BrowserInfo {
if (typeof navigator === 'undefined') {
return { browserName: 'unknown', browserVersion: 'unknown' };
}
const nav = navigator as NavigatorWithBrandHints;
const ua = nav.userAgent;
// Order matters: derivatives put "Chrome" in their UA; Chrome puts "Safari".
const matchers: { name: string; re: RegExp }[] = [
{ name: 'Edge', re: /Edg(?:e|A|iOS)?\/([\d.]+)/ },
{ name: 'Opera', re: /OPR\/([\d.]+)/ },
{ name: 'Vivaldi', re: /Vivaldi\/([\d.]+)/ },
{ name: 'Chrome', re: /Chrome\/([\d.]+)/ },
{ name: 'Firefox', re: /Firefox\/([\d.]+)/ },
{ name: 'Safari', re: /Version\/([\d.]+).*Safari/ },
];
let browserName = 'unknown';
let browserVersion = 'unknown';
for (const { name, re } of matchers) {
const m = ua.match(re);
if (m) {
browserName = name;
browserVersion = m[1];
break;
}
}
// Brave hides as Chrome in UA + brand hints; its probe is the only tell.
if (nav.brave?.isBrave) {
browserName = 'Brave';
}
return { browserName, browserVersion };
}
export const SuggestedPromptCategory = {
FollowUp: 'follow_up',
EmptyState: 'empty_state',
@@ -24,6 +76,15 @@ export const AIAssistantOpenSource = {
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
// `source` attribute on the `VoiceInputUsed` event — which surface initiated
// the recording.
export const VoiceInputSource = {
Button: 'button',
Shortcut: 'shortcut',
} as const;
export type VoiceInputSource =
(typeof VoiceInputSource)[keyof typeof VoiceInputSource];
export enum AIAssistantEvents {
Opened = 'AI Assistant: Opened',
MessageSent = 'AI Assistant: Message sent',
@@ -37,5 +98,7 @@ export enum AIAssistantEvents {
DocOpened = 'AI Assistant: Doc opened',
ApplyFilterClicked = 'AI Assistant: Apply filter clicked',
ThreadOpenedFromHistory = 'AI Assistant: Thread opened from history',
VoiceInputUsed = 'AI Assistant: Voice input used',
VoiceInputFailed = 'AI Assistant: Voice input failed',
NewChatClicked = 'AI Assistant: New chat clicked',
}

View File

@@ -0,0 +1,268 @@
import { useCallback, useEffect, useRef, useState } from 'react';
// ── Web Speech API types (not yet in lib.dom.d.ts) ────────────────────────────
interface SpeechRecognitionResult {
readonly length: number;
readonly isFinal: boolean;
[index: number]: { transcript: string; confidence: number };
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognitionErrorEvent extends Event {
readonly error: string;
readonly message: string;
}
interface ISpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onstart: (() => void) | null;
onend: (() => void) | null;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
start(): void;
stop(): void;
abort(): void;
}
type SpeechRecognitionConstructor = new () => ISpeechRecognition;
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
// Some hardened/enterprise browsers install a getter
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
// due to your security policy") instead of leaving the property undefined.
// Because this resolves at module-evaluation time, an uncaught throw here aborts
// the entire bundle and the app renders a blank page. Read defensively so a
// throwing getter degrades to "unsupported" rather than crashing the app.
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') {
return null;
}
try {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).SpeechRecognition ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).webkitSpeechRecognition ??
null
);
} catch {
return null;
}
}
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
resolveSpeechRecognitionAPI();
export type SpeechRecognitionError =
| 'not-supported'
| 'not-allowed'
| 'no-speech'
| 'network'
| 'unknown';
export type MicrophonePermission = 'prompt' | 'granted' | 'denied';
interface UseSpeechRecognitionOptions {
onError?: (error: SpeechRecognitionError) => void;
/**
* Called directly from browser recognition events — no React state intermediary.
* `isFinal=false` → interim (live preview), `isFinal=true` → committed text.
*/
onTranscript?: (text: string, isFinal: boolean) => void;
lang?: string;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
isSupported: boolean;
/**
* Browser microphone permission. Starts as `'prompt'` (or whatever the
* Permissions API reports on mount), flips to `'granted'` once a session
* successfully starts, and to `'denied'` if the user blocks access.
*/
permission: MicrophonePermission;
start: () => void;
stop: () => void;
/** Stop recognition and discard any pending interim text (no onTranscript call). */
discard: () => void;
}
export function useSpeechRecognition({
onError,
onTranscript,
lang = 'en-US',
}: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const [permission, setPermission] = useState<MicrophonePermission>('prompt');
const recognitionRef = useRef<ISpeechRecognition | null>(null);
const isDiscardingRef = useRef(false);
const isSupported = SpeechRecognitionAPI !== null;
// Seed `permission` from the Permissions API and keep it in sync with
// external changes (e.g. user resets the site permission in browser
// settings). Falls back silently if the API is unavailable (Safari) —
// the recognition lifecycle below will still flip the state on grant
// or deny.
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.permissions?.query) {
return undefined;
}
let status: PermissionStatus | null = null;
let cancelled = false;
const handleChange = (): void => {
if (status) {
setPermission(status.state as MicrophonePermission);
}
};
navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((permStatus) => {
if (cancelled) {
return;
}
status = permStatus;
setPermission(permStatus.state as MicrophonePermission);
permStatus.addEventListener('change', handleChange);
})
.catch(() => {
// Permissions API rejected (some browsers don't recognise the
// 'microphone' descriptor) — leave permission as 'prompt'.
});
return (): void => {
cancelled = true;
status?.removeEventListener('change', handleChange);
};
}, []);
// Always-current refs — updated synchronously on every render so closures
// inside recognition event handlers always call the latest version.
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const onTranscriptRef = useRef(onTranscript);
onTranscriptRef.current = onTranscript;
const stop = useCallback(() => {
recognitionRef.current?.stop();
}, []);
const discard = useCallback(() => {
isDiscardingRef.current = true;
recognitionRef.current?.stop();
}, []);
// eslint-disable-next-line sonarjs/cognitive-complexity
const start = useCallback(() => {
if (!isSupported) {
onErrorRef.current?.('not-supported');
return;
}
// If already listening, stop
if (recognitionRef.current) {
recognitionRef.current.stop();
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const recognition = new SpeechRecognitionAPI!();
recognition.lang = lang;
recognition.continuous = true; // keep listening until user clicks stop
recognition.interimResults = true; // live updates while speaking
// Track the last interim text so we can commit it as final in onend —
// Chrome often skips the isFinal result when stop() is called manually.
let pendingInterim = '';
recognition.onstart = (): void => {
setIsListening(true);
setPermission('granted');
};
recognition.onresult = (event): void => {
// Browser fires a final `onresult` after `recognition.stop()` —
// when the caller invoked `discard()` (e.g. stop-and-send) the
// React state has already been cleared / the message has
// already been dispatched, so a late transcript would
// repopulate the textarea. Drop these events while discarding.
if (isDiscardingRef.current) {
pendingInterim = '';
return;
}
let interim = '';
let finalText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += text;
} else {
interim += text;
}
}
if (finalText) {
pendingInterim = '';
onTranscriptRef.current?.(finalText, true);
} else if (interim) {
pendingInterim = interim;
onTranscriptRef.current?.(interim, false);
}
};
recognition.onerror = (event): void => {
pendingInterim = '';
let mapped: SpeechRecognitionError = 'unknown';
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
mapped = 'not-allowed';
setPermission('denied');
} else if (event.error === 'no-speech') {
mapped = 'no-speech';
} else if (event.error === 'network') {
mapped = 'network';
}
onErrorRef.current?.(mapped);
};
recognition.onend = (): void => {
// Commit any interim text that never received a final result,
// unless the session was explicitly discarded.
if (!isDiscardingRef.current && pendingInterim) {
const committed = pendingInterim;
pendingInterim = '';
onTranscriptRef.current?.(committed, true);
}
isDiscardingRef.current = false;
pendingInterim = '';
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
}, [isSupported, lang]);
// Clean up on unmount
useEffect(
() => (): void => {
recognitionRef.current?.abort();
},
[],
);
return { isListening, isSupported, permission, start, stop, discard };
}

View File

@@ -630,7 +630,6 @@ describe('Footer utils', () => {
expect(props.ruleType).toBe('anomaly_rule');
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.condition.algorithm).toBe('standard');
expect(props.condition.seasonality).toBe('daily');
expect(props.condition.thresholds?.spec[0]).toMatchObject({
target: 3,

View File

@@ -324,7 +324,6 @@ export function buildCreateAnomalyAlertRulePayload(
condition: {
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
},
};

View File

@@ -323,7 +323,6 @@ describe('CreateAlert utils', () => {
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'weekly',
thresholds: {
kind: 'basic',
@@ -349,7 +348,6 @@ describe('CreateAlert utils', () => {
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
operator: AlertThresholdOperator.IS_BELOW,
thresholds: [{ thresholdValue: 3 }],
algorithm: 'standard',
seasonality: 'weekly',
});
});

View File

@@ -274,8 +274,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
};

View File

@@ -1,12 +1,10 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
@@ -23,18 +21,7 @@ interface HomeAlertRules {
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { cacheTime: 0 },
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(

View File

@@ -104,5 +104,5 @@ describe('AI observability Explorer', () => {
...defaultOptionsQuery,
selectColumns: [],
});
});
}, 15_000);
});

View File

@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
jest.setTimeout(15_000);
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";

View File

@@ -3,13 +3,11 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -35,18 +33,7 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { keepPreviousData: true },
});
const rules = useMemo(

View File

@@ -0,0 +1,32 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { back, getCurrentLocation } from 'lib/router/navigation';
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
// jest.config.ts maps `hooks/useSafeNavigate` to a mock; the `.ts` path loads the real hook.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('hooks/useSafeNavigate.ts');
describe('useSafeNavigate', () => {
it('adds a history entry for replace: false even when the target adds params', async () => {
const initialSearch = `?${QueryParams.relativeTime}=15m`;
const { result } = renderHook(() => useSafeNavigate(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${ROUTES.LOGS_EXPLORER}${initialSearch}`}>
{children}
</AllTheProviders>
),
});
act(() => {
result.current.safeNavigate(
`${ROUTES.LOGS_EXPLORER}?${QueryParams.startTime}=1000`,
{ replace: false },
);
});
act(() => back());
await waitFor(() => expect(getCurrentLocation().search).toBe(initialSearch));
});
});

View File

@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
},
redirectToUrl,
undefined,
newTab,
{ newTab },
);
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],

View File

@@ -140,7 +140,7 @@ export const useSafeNavigate = (
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
replace: options?.replace ?? isDefaultParamsNavigation,
};
if (typeof to === 'string') {

View File

@@ -4,9 +4,11 @@
*/
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
import removeSessionStorage from 'api/browser/sessionstorage/remove';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { rest, type RequestHandler } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -270,3 +272,58 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
);
},
);
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
interface StorySpeechRecognition {
listening: {
hear: (transcript: string) => void;
onerror: ((event: { error: string }) => void) | null;
} | null;
}
/**
* Clicks the composer's mic and waits for the recording to start. The composer
* stays disabled until the thread has loaded.
*/
export const startVoiceInput = async (
canvasElement: HTMLElement,
): Promise<void> => {
const canvas = within(canvasElement);
const mic = await waitFor(
async () => {
const button = canvas.getByRole('button', { name: /start voice input/i });
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
await expect(button).toBeEnabled();
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
return button;
},
{ timeout: 15_000 },
);
await userEvent.click(mic);
await canvas.findByRole('status', { name: /recording voice input/i });
};
/** Speaks into the open recording: the words so far, still listening. */
export const speak = (transcript: string): void => {
(
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
).webkitSpeechRecognition.listening?.hear(transcript);
};
/**
* Clears the flag `ChatInput` keeps in sessionStorage after a failed recording,
* which hides the mic for the rest of the tab, test-runner's included.
*/
export const forgetVoiceFailure = (): void => {
removeSessionStorage('ai-assistant-voice-unavailable');
};
/** Fails the open recording the way the browser's recognizer reports it. */
export const failVoiceInput = (error: 'network' | 'not-allowed'): void => {
(
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
).webkitSpeechRecognition.listening?.onerror?.({ error });
};

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { renderAtRoute } from '@/storybook/renderAtRoute';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -8,17 +9,13 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import AIAssistantPage from '../AIAssistantPage';
import {
aiAssistantMocks,
failVoiceInput,
forgetVoiceFailure,
longActionTooltipHandlers,
speak,
startVoiceInput,
} from './AIAssistantPage.stories.mocks';
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
import {
copyConversationLink,
expandActivity,
openApprovalDiff,
openContextPicker,
openConversationActions,
openNegativeFeedback,
} from './__story_mockdata__/aiAssistant.play';
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
@@ -43,12 +40,40 @@ const meta = {
),
...pageStory,
parameters: { ...pageStory.parameters },
beforeEach: forgetVoiceFailure,
} satisfies Meta<AIAssistantArgs>;
export default meta;
type Story = StoryObj<AIAssistantArgs>;
/** The thread list resolves before the thread does, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** How long what a click opened has to stay on screen to count as open. */
const HOLD_MS = 1_000;
/**
* Click something, and keep clicking until what it opens is on screen. The
* message list remounts its items while it measures a freshly loaded thread, so
* a single click can land on a row that is about to be replaced, taking the
* state it just set with it. The replacement can land after that state has
* rendered, so it has to still be there `HOLD_MS` later.
*/
const clickUntil = async (
find: () => Promise<HTMLElement>,
opens: RegExp,
): Promise<void> => {
await waitFor(async () => {
await userEvent.click(await find());
await screen.findByText(opens, undefined, { timeout: 1_000 });
await new Promise((resolve) => {
setTimeout(resolve, HOLD_MS);
});
screen.getByText(opens);
}, untilLoaded);
};
/** The blocks the agent renders as cards the user answers in place. */
const INTERACTIVE: ThreadPart[] = [
'question',
@@ -124,7 +149,33 @@ export const LoadingThread: Story = {
* with what went in and what came back.
*/
export const ActivityExpanded: Story = {
play: expandActivity,
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await clickUntil(
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
/compared checkout p99/i,
);
await clickUntil(
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
/^Output$/,
);
},
};
/** Opens the approval card's diff dialog. */
const openApprovalDiff: NonNullable<Story['play']> = async ({
canvasElement,
}) => {
await clickUntil(
() =>
within(canvasElement).findByLabelText(
/expand diff/i,
undefined,
untilLoaded,
),
/approval diff/i,
);
};
/** The approval diff at full size, before against after. */
@@ -133,14 +184,66 @@ export const ApprovalDiff: Story = {
play: openApprovalDiff,
};
/**
* Dictating a question: the mic listening, the words heard so far in the
* composer, and the controls to discard them or stop and send.
*/
export const VoiceRecording: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
speak('why did checkout p99 jump after the 14:00 deploy');
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
},
};
/** The recognizer reporting it cannot reach the speech service. */
export const VoiceInputUnavailableToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
failVoiceInput('network');
await screen.findByText(/voice input unavailable in this browser/i);
},
};
/** The browser refusing the microphone. */
export const MicrophoneDeniedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
failVoiceInput('not-allowed');
await screen.findByText(/microphone access denied/i);
},
};
/** The comment box a thumbs down opens, which a thumbs up does not. */
export const NegativeFeedback: Story = {
play: openNegativeFeedback,
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
// Every assistant message carries the bar; only the last one shows it
// without a hover.
const bars = await within(canvasElement).findAllByLabelText(
/bad response/i,
undefined,
untilLoaded,
);
return bars[bars.length - 1];
}, /what went wrong/i);
},
};
/** What a conversation row offers: rename, a link to it, and archiving. */
export const ConversationActions: Story = {
play: openConversationActions,
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /copy link/i);
},
};
/**
@@ -148,17 +251,39 @@ export const ConversationActions: Story = {
* can be pinned to.
*/
export const AddContext: Story = {
play: openContextPicker,
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(
() =>
within(canvasElement).findByRole(
'button',
{ name: /add context/i },
untilLoaded,
),
/checkout overview/i,
);
},
};
/** "Copy link" on a conversation row, which toasts once the link is copied. */
export const ConversationLinkCopiedToast: Story = {
play: copyConversationLink,
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /copy link/i);
await userEvent.click(await screen.findByText(/copy link/i));
await screen.findByText(/conversation link copied to clipboard/i);
},
};
/**
* Every tooltip the thread carries, held open at once: the composer's add context
* and send buttons, the sidebar's new conversation, the copy chip under each user
* Every tooltip the thread carries, held open at once: the composer's voice and
* send buttons, the sidebar's new conversation, the copy chip under each user
* message, the copy, rate and regenerate bar under each answer, the code block's
* copy, and the action chip's own description.
*/
@@ -176,3 +301,13 @@ export const TooltipsInApprovalDiff: Story = {
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
play: openApprovalDiff,
};
/**
* The recording controls' tooltips, held open: discard, stop and send, and the
* send button beside them, which waits for words.
*/
export const TooltipsInVoiceRecording: Story = {
args: { tooltipsOpen: true, contents: BRIEF },
play: async ({ canvasElement }): Promise<void> =>
startVoiceInput(canvasElement),
};

View File

@@ -1,119 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { screen, userEvent, waitFor, within } from 'storybook/test';
interface PlayContext {
canvasElement: HTMLElement;
}
/** The thread list resolves before the thread does, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** How long what a click opened has to stay on screen to count as open. */
const HOLD_MS = 1_000;
/**
* Click something, and keep clicking until what it opens is on screen. The
* message list remounts its items while it measures a freshly loaded thread, so
* a single click can land on a row that is about to be replaced, taking the
* state it just set with it. The replacement can land after that state has
* rendered, so it has to still be there `HOLD_MS` later.
*/
const clickUntil = async (
find: () => Promise<HTMLElement>,
opens: RegExp,
): Promise<void> => {
await waitFor(async () => {
await userEvent.click(await find());
await screen.findByText(opens, undefined, { timeout: 1_000 });
await new Promise((resolve) => {
setTimeout(resolve, HOLD_MS);
});
screen.getByText(opens);
}, untilLoaded);
};
export const expandActivity = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
await clickUntil(
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
/compared checkout p99/i,
);
await clickUntil(
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
/^Output$/,
);
};
/** Opens the approval card's diff dialog. */
export const openApprovalDiff = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await clickUntil(
() =>
within(canvasElement).findByLabelText(
/expand diff/i,
undefined,
untilLoaded,
),
/approval diff/i,
);
};
export const openNegativeFeedback = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await clickUntil(async () => {
// Every assistant message carries the bar; only the last one shows it
// without a hover.
const bars = await within(canvasElement).findAllByLabelText(
/bad response/i,
undefined,
untilLoaded,
);
return bars[bars.length - 1];
}, /what went wrong/i);
};
export const openConversationActions = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /copy link/i);
};
export const openContextPicker = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await clickUntil(
() =>
within(canvasElement).findByRole(
'button',
{ name: /add context/i },
untilLoaded,
),
/checkout overview/i,
);
};
export const copyConversationLink = async (
context: PlayContext,
): Promise<void> => {
await openConversationActions(context);
await userEvent.click(await screen.findByText(/copy link/i));
await screen.findByText(/conversation link copied to clipboard/i);
};

View File

@@ -4,6 +4,7 @@
*/
import { rest, type ResponseComposition, type RestContext } from 'msw';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { screen, userEvent, within } from 'storybook/test';
import {
@@ -12,7 +13,10 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { accessFor } from '@/storybook/access/access';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
import { componentPermission } from 'utils/permission';
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
import {
DEFAULT_RULE_VIEW,
@@ -281,3 +285,37 @@ export const runRowAction = async (
await userEvent.click(actions);
await userEvent.click(await screen.findByRole('menuitem', { name }));
};
export type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
export const listedRules = (
args: AlertRulesArgs,
): RuletypesListableRuleDTO[] => {
const params = new URL(listRoute(args.view), 'http://localhost').searchParams;
if (
args.dataState !== 'loaded' ||
(args.invalidQuery !== 'off' && params.get('query'))
) {
return [];
}
return alertRulesPage(
args.rules,
{ severity: args.ruleSeverity, state: args.ruleState },
params,
).data.rules;
};
export const hasRowMenu = (args: AlertRulesArgs): boolean =>
listedRules(args).length > 0 &&
componentPermission.action.includes(
accessFor(args.access, args.permissions, args.revoked).legacyRole,
);
export const showsNoRulesPage = (args: AlertRulesArgs): boolean =>
args.view === DEFAULT_RULE_VIEW &&
args.dataState === 'loaded' &&
args.rules === 0;
export const listsSavedViews = (args: AlertRulesArgs): boolean =>
args.ruleViews > 0 && args.ruleViewsState === 'success';

View File

@@ -1,9 +1,11 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertRulesMocks } from './AlertRules.stories.mocks';
import {
alertRulesMocks,
type AlertRulesArgs,
} from './AlertRules.stories.mocks';
import {
cloneAlert,
copyLabel,
@@ -22,8 +24,6 @@ import {
import AlertList from '../../index';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
/**

View File

@@ -3,20 +3,22 @@
* Do not hand-edit: regenerate instead.
*/
import { DEFAULT_RULE_VIEW } from 'container/ListAlertRules/list.config';
import { expect, screen, userEvent, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import {
alertRulesMocks,
type AlertRulesArgs,
hasRowMenu,
listedRules,
listsSavedViews,
runRowAction,
showsNoRulesPage,
untilLoaded,
} from '../AlertRules.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof alertRulesMocks>;
args: AlertRulesArgs;
}
// A row's rename and delete only take the pointer while the row is hovered,
@@ -25,10 +27,15 @@ const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
export const openQuerySuggestions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (listedRules(args).length === 0) {
return;
}
const canvas = within(canvasElement);
// The labels come with the rule list, so the box is opened once rows are in.
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
await canvas.findAllByTestId(/^alert-row-.+-name$/, undefined, untilLoaded);
const search = canvas.getByTestId('alert-rules-list-search');
await userEvent.click(search.querySelector('.cm-content') ?? search);
await screen.findByText('labels.team');
@@ -49,7 +56,12 @@ export const openSaveViewPopover = async ({
export const openRenameViewPopover = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-rename-view-1',
@@ -57,12 +69,17 @@ export const openRenameViewPopover = async ({
untilLoaded,
),
);
await screen.findByText('Rename view');
await screen.findAllByText('Rename view');
};
export const openDeleteViewConfirm = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-delete-view-1',
@@ -75,7 +92,12 @@ export const openDeleteViewConfirm = async ({
export const openRowActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
@@ -88,7 +110,12 @@ export const openRowActions = async ({
export const openDisabledRuleActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
@@ -96,12 +123,21 @@ export const openDisabledRuleActions = async ({
);
await userEvent.click(actions);
await screen.findByText(/^enable$/i);
await screen.findByText(
listedRules(args)[0].state === RuletypesAlertStateDTO.disabled
? /^enable$/i
: /^disable$/i,
);
};
export const openColumnPicker = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (showsNoRulesPage(args)) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-columns-button',
@@ -116,11 +152,7 @@ export const openStatesFilter = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
args.rules === 0 &&
args.view === DEFAULT_RULE_VIEW &&
args.dataState === 'loaded'
) {
if (showsNoRulesPage(args)) {
return;
}
@@ -144,6 +176,10 @@ export const toggleAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^(enable|disable)$/i);
if (args.toggleState === 'success') {
await expect(
@@ -156,6 +192,10 @@ export const cloneAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^clone$/i);
if (args.cloneState === 'success') {
await expect(
@@ -168,6 +208,10 @@ export const deleteAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!hasRowMenu(args)) {
return;
}
await runRowAction(canvasElement, /^delete$/i);
if (args.deleteState === 'success') {
await expect(
@@ -176,7 +220,11 @@ export const deleteAlert = async ({
}
};
export const copyLabel = async (): Promise<void> => {
export const copyLabel = async ({ args }: PlayContext): Promise<void> => {
if (!args.tooltipsOpen || listedRules(args).length === 0) {
return;
}
const [copy] = await screen.findAllByRole(
'button',
{ name: 'Copy to clipboard' },
@@ -184,5 +232,7 @@ export const copyLabel = async (): Promise<void> => {
);
await userEvent.click(copy);
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
};

View File

@@ -16,6 +16,12 @@ import {
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
DashboardCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import {
expect,
mocked,
@@ -24,7 +30,9 @@ import {
waitFor,
within,
} from 'storybook/test';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
import { grantsAll } from '@/storybook/access/access';
import {
choiceControl,
countControl,
@@ -32,6 +40,8 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { appShellMocks } from '@/storybook/globals/appShellMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
orgUsersResponse,
@@ -67,6 +77,8 @@ import {
type DashboardViewId,
findBuiltinView,
} from '../list.config';
import { LIST_CHECKS } from '../constants/permissions';
import type { DashboardListItem } from '../utils/helpers';
const LIST = 'Dashboards · list';
const VIEWS = 'Dashboards · views';
@@ -528,6 +540,81 @@ export const dashboardsListMocks = defineStoryMocks({
},
});
export type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const PAGE_SIZE = 20;
export const canList = (args: DashboardsListArgs): boolean =>
grantsAll(args, ...LIST_CHECKS);
/** `AuthZButton` leaves New dashboard enabled when the check itself fails. */
export const canCreate = (args: DashboardsListArgs): boolean =>
args.authzState === 'error' || grantsAll(args, DashboardCreatePermission);
export const canUpdate = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
grantsAll(
args,
buildDashboardReadPermission(row.id),
buildDashboardUpdatePermission(row.id),
);
export const canDuplicate = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
grantsAll(
args,
buildDashboardReadPermission(row.id),
DashboardCreatePermission,
);
export const canDelete = (
args: DashboardsListArgs,
row: DashboardListItem,
): boolean =>
!row.locked && grantsAll(args, buildDashboardDeletePermission(row.id));
export const listedDashboards = (
args: DashboardsListArgs,
): DashboardListItem[] => {
if (args.dataState !== 'loaded' || args.invalidQuery || !canList(args)) {
return [];
}
const view = findBuiltinView(args.view);
const rows = dashboardsListResponse({
count: args.dashboards,
offset: 0,
limit: view?.clientFilter ? args.dashboards : PAGE_SIZE,
markers: args.markers,
query:
new URL(listRoute(args.view), 'http://localhost').searchParams.get(
'query',
) ?? '',
}).data.dashboards;
return view?.clientFilter
? view.clientFilter(rows, {
userEmail: STORY_USER_EMAIL,
recent: recentDashboardIds(RECENT_COUNT),
})
: rows;
};
export const showsWorkspaceEmpty = (args: DashboardsListArgs): boolean =>
args.view === DEFAULT_DASHBOARD_VIEW &&
args.dataState === 'loaded' &&
!args.invalidQuery &&
canList(args) &&
args.dashboards === 0;
export const isCloudTenant = (args: DashboardsListArgs): boolean =>
appShellMocks.config?.(appShellMocks.read(args)).appContext?.activeLicense
?.platform === LicensePlatform.CLOUD;
/** Opens the actions menu of the row at `index`. */
export const openRowActions = async (
canvasElement: HTMLElement,
@@ -541,7 +628,7 @@ export const openRowActions = async (
);
await userEvent.click(triggers[index]);
await screen.findByText('Rename');
await screen.findByTestId('dashboard-action-delete');
};
/** Picks a row action, retrying while its permission check still disables it. */
@@ -574,10 +661,11 @@ export const TOAST_TIMEOUT = 10000;
export const openLegacyDialog = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-title-4',
`dashboard-title-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
@@ -587,8 +675,9 @@ export const openLegacyDialog = async (
export const retryMigration = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
await openLegacyDialog(canvasElement);
await openLegacyDialog(canvasElement, index);
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
await waitFor(

View File

@@ -1,47 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
type DashboardsListArgs,
longQueryRoute,
overflowingRows,
} from './DashboardsListPage.stories.mocks';
import {
copyLegacyDashboardId,
createDashboardFails,
createDashboard,
deleteDashboard,
deleteViewFails,
deleteView,
duplicateDashboard,
importCreateFails,
importDashboard,
importInvalidImage,
importInvalidJson,
lockDashboard,
migrateDashboard,
migrationFails,
openCreatedByFilter,
openEditTagsDialog,
openFirstRowActions,
openRenameDialog,
openSaveViewPopover,
openTemplateTab,
pinFails,
pinPastLimit,
pinDashboard,
renameDashboard,
renameViewFails,
saveViewFails,
renameView,
saveView,
submitTemplateRequest,
templateRequestFails,
templateRequestRejected,
unlockDashboard,
unpinFails,
unpinDashboard,
updateTags,
} from './__story_mockdata__/dashboardsList.play';
import DashboardsListPage from '../DashboardsListPage';
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
const pageStory = storyMocks(dashboardsListMocks, { layout: 'app' });
/**
@@ -182,21 +176,21 @@ export const DashboardDeletedToast: Story = {
/** Pinning past the limit: the backend refuses with a 409. */
export const PinLimitToast: Story = {
args: { pinWrite: 'limit' },
play: pinPastLimit,
play: pinDashboard,
parameters: { allowConsoleErrors: true },
};
/** A pin the backend failed to write. */
export const PinFailedToast: Story = {
args: { pinWrite: 'error' },
play: pinFails,
play: pinDashboard,
parameters: { allowConsoleErrors: true },
};
/** An unpin the backend failed to write, from the first (pinned) row. */
export const UnpinFailedToast: Story = {
args: { pinWrite: 'error' },
play: unpinFails,
play: unpinDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -213,14 +207,14 @@ export const DashboardMigratedToast: Story = {
/** Retry migration of the legacy dashboard refused by the backend. */
export const DashboardMigrationFailedToast: Story = {
args: { migrationWrite: 'error' },
play: migrationFails,
play: migrateDashboard,
parameters: { allowConsoleErrors: true },
};
/** The Blank tab's create failing: the toast raised beside the error modal. */
export const CreateDashboardFailedToast: Story = {
args: { createWrite: 'error' },
play: createDashboardFails,
play: createDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -232,7 +226,7 @@ export const ImportJsonInvalidImageToast: Story = {
/** A valid dashboard file the create endpoint refuses. */
export const ImportJsonCreateFailedToast: Story = {
args: { createWrite: 'error' },
play: importCreateFails,
play: importDashboard,
parameters: { allowConsoleErrors: true },
};
@@ -244,33 +238,33 @@ export const TemplateRequestSubmittedToast: Story = {
/** A template request the analytics endpoint answered with a failure. */
export const TemplateRequestFailedToast: Story = {
args: { templateRequest: 'error' },
play: templateRequestFails,
play: submitTemplateRequest,
};
/** A template request that threw before an answer came back. */
export const TemplateRequestRejectedToast: Story = {
args: { templateRequest: 'rejected' },
play: templateRequestRejected,
play: submitTemplateRequest,
};
/** Saving the current filters as a view, refused by the backend. */
export const SaveViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: saveViewFails,
play: saveView,
parameters: { allowConsoleErrors: true },
};
/** Renaming a saved view, refused by the backend. */
export const RenameViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: renameViewFails,
play: renameView,
parameters: { allowConsoleErrors: true },
};
/** Deleting a saved view, refused by the backend. */
export const DeleteViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: deleteViewFails,
play: deleteView,
parameters: { allowConsoleErrors: true },
};

View File

@@ -5,30 +5,68 @@
import { expect, screen, userEvent, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
canCreate,
canDelete,
canDuplicate,
canList,
canUpdate,
clickRowAction,
dashboardsListMocks,
type DashboardsListArgs,
isCloudTenant,
listedDashboards,
openLegacyDialog,
openRowActions,
pickRowAction,
requestTemplate,
retryMigration,
showsWorkspaceEmpty,
TOAST_TIMEOUT,
uploadDashboardJson,
} from '../DashboardsListPage.stories.mocks';
import type { TemplateRequest } from './dashboardsList';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof dashboardsListMocks>;
args: DashboardsListArgs;
}
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
const TEMPLATE_REQUEST_TOASTS = {
success: 'Dashboard request submitted',
error: 'Event rejected',
rejected: 'Something went wrong',
} satisfies Record<TemplateRequest, string>;
const openNewDashboard = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
};
const canEditRow = (args: DashboardsListArgs, index: number): boolean => {
const row = listedDashboards(args)[index];
return !!row && !row.legacy && !row.locked && canUpdate(args, row);
};
export const openCreatedByFilter = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
!canList(args) ||
args.dataState !== 'loaded' ||
showsWorkspaceEmpty(args)
) {
return;
}
const filter = await within(canvasElement).findByTestId(
'dashboards-filter-created-by',
{},
@@ -37,18 +75,18 @@ export const openCreatedByFilter = async ({
await userEvent.click(filter.querySelector('.ant-select-selector') ?? filter);
await userEvent.click(await screen.findByText('Ada (ada@signoz.io)'));
await screen.findByRole('checkbox', { checked: true });
await screen.findAllByRole('checkbox', { checked: true });
};
export const openTemplateTab = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await expect(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
).toHaveTextContent('New dashboard');
@@ -58,12 +96,13 @@ export const openTemplateTab = async ({
export const importInvalidJson = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
if (!canCreate(args)) {
return;
}
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await openNewDashboard(canvasElement);
await userEvent.click(await screen.findByText('Import JSON'));
await userEvent.click(await screen.findByTestId('import-json-submit'));
await screen.findByText(/error loading json/i);
@@ -73,13 +112,23 @@ export const importInvalidJson = async ({
export const openFirstRowActions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (listedDashboards(args).length === 0) {
return;
}
await openRowActions(canvasElement, 0);
};
export const openRenameDialog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
@@ -87,7 +136,12 @@ export const openRenameDialog = async ({
export const openEditTagsDialog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
@@ -95,7 +149,12 @@ export const openEditTagsDialog = async ({
export const openSaveViewPopover = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canList(args)) {
return;
}
await userEvent.click(
await within(canvasElement).findByRole(
'button',
@@ -110,6 +169,10 @@ export const renameDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
const input = await screen.findByTestId('rename-dashboard-input');
@@ -126,6 +189,10 @@ export const updateTags = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditRow(args, 1)) {
return;
}
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
const input = await screen.findByPlaceholderText('key:value (press Enter)');
@@ -141,11 +208,37 @@ export const duplicateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const row = listedDashboards(args)[1];
if (!row || row.legacy || !canDuplicate(args, row)) {
return;
}
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-duplicate');
if (args.rowWrite === 'success') {
await screen.findByText(
'Duplicated "API latency and errors"',
`Duplicated "${row.name}"`,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
const toggleLockAt = async (
canvasElement: HTMLElement,
args: DashboardsListArgs,
index: number,
): Promise<void> => {
const row = listedDashboards(args)[index];
if (!row || row.legacy || !canUpdate(args, row)) {
return;
}
await openRowActions(canvasElement, index);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText(
row.locked ? 'Dashboard unlocked' : 'Dashboard locked',
{},
{ timeout: TOAST_TIMEOUT },
);
@@ -155,29 +248,22 @@ export const duplicateDashboard = async ({
export const lockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
}
};
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 1);
export const unlockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 0);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
}
};
}: PlayContext): Promise<void> => toggleLockAt(canvasElement, args, 0);
export const deleteDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const row = listedDashboards(args)[1];
if (!row || !canDelete(args, row)) {
return;
}
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-delete');
const dialog = await screen.findByRole('dialog');
@@ -194,61 +280,73 @@ export const deleteDashboard = async ({
}
};
export const pinPastLimit = async ({
export const pinDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const index = listedDashboards(args).findIndex((row) => !row.pinned);
if (index < 0) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
`dashboard-pin-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.pinWrite === 'limit') {
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
if (args.pinWrite === 'error') {
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const pinFails = async ({
export const unpinDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
const index = listedDashboards(args).findIndex((row) => row.pinned);
if (index < 0) {
return;
}
export const unpinFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-0',
`dashboard-pin-${index}`,
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.pinWrite === 'error') {
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const copyLegacyDashboardId = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openLegacyDialog(canvasElement);
const index = listedDashboards(args).findIndex((row) => row.legacy);
if (index < 0) {
return;
}
await openLegacyDialog(canvasElement, index);
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
};
@@ -257,7 +355,13 @@ export const migrateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
const rows = listedDashboards(args);
const index = rows.findIndex((row) => row.legacy);
if (index < 0 || !canUpdate(args, rows[index])) {
return;
}
await retryMigration(canvasElement, index);
if (args.migrationWrite === 'success') {
await screen.findByText(
'Dashboard migrated to the new experience',
@@ -265,47 +369,43 @@ export const migrateDashboard = async ({
{ timeout: TOAST_TIMEOUT },
);
}
};
export const migrationFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const createDashboardFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
if (args.migrationWrite === 'error') {
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
),
);
);
}
};
export const createDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.createWrite === 'error') {
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const importInvalidImage = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await uploadDashboardJson({
schemaVersion: 'v6',
image: 'https://example.com/logo.png',
@@ -318,59 +418,52 @@ export const importInvalidImage = async ({
);
};
export const importCreateFails = async ({
export const importDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
if (!canCreate(args)) {
return;
}
await openNewDashboard(canvasElement);
await uploadDashboardJson({
schemaVersion: 'v6',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.createWrite === 'error') {
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const submitTemplateRequest = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canCreate(args) || !isCloudTenant(args)) {
return;
}
await requestTemplate(canvasElement);
await screen.findByText(
'Dashboard request submitted',
TEMPLATE_REQUEST_TOASTS[args.templateRequest],
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const templateRequestFails = async ({
export const saveView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
};
if (!canList(args)) {
return;
}
export const templateRequestRejected = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const saveViewFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
@@ -380,16 +473,26 @@ export const saveViewFails = async ({
);
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
await userEvent.click(await screen.findByTestId('save-view-confirm'));
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const renameViewFails = async ({
const listsSavedViews = (args: DashboardsListArgs): boolean =>
canList(args) && args.dataState === 'loaded' && args.savedViews > 0;
export const renameView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
const [rename] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Rename view' },
@@ -403,16 +506,23 @@ export const renameViewFails = async ({
await userEvent.clear(input);
await userEvent.type(input, 'Renamed view');
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const deleteViewFails = async ({
export const deleteView = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsSavedViews(args)) {
return;
}
const [remove] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Delete view' },
@@ -425,9 +535,11 @@ export const deleteViewFails = async ({
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
if (args.viewWrite === 'error') {
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};

View File

@@ -9,6 +9,8 @@ import {
InfraMonitoringEntity,
} from 'container/InfraMonitoringK8sV2/constants';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { infraStoryMocks } from './InfrastructureMonitoring.stories.mocks';
import { infraListResponse } from './__story_mockdata__/infraMonitoring';
@@ -56,6 +58,7 @@ export const podLongNodeName = rest.post(
* what puts `?category=` on the route the story starts on.
*/
export const podsMocks = infraStoryMocks(InfraMonitoringEntity.PODS);
export type PodsArgs = PageStoryArgs<typeof podsMocks>;
export const nodesMocks = infraStoryMocks(InfraMonitoringEntity.NODES);
export const namespacesMocks = infraStoryMocks(
InfraMonitoringEntity.NAMESPACES,

View File

@@ -1,21 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { podLongNodeName, podsMocks } from './Kubernetes.stories.mocks';
import {
podLongNodeName,
podsMocks,
type PodsArgs,
} from './Kubernetes.stories.mocks';
import {
copyPodMetadata,
copyPodName,
openDrawerLogsTab,
openTableOptions,
suggestFilterKeys,
suggestFilterValues,
} from './__story_mockdata__/pods.play';
import InfrastructureMonitoringPage from '../InfrastructureMonitoringPage';
type PodsArgs = PageStoryArgs<typeof podsMocks>;
const pageStory = storyMocks(podsMocks, { layout: 'app' });
/**
@@ -57,38 +60,19 @@ export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
},
play: openDrawerLogsTab,
};
/** The copy button beside the pod name in the drawer, which toasts the copy. */
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
);
await screen.findByText('Pod name copied to clipboard');
},
play: copyPodName,
};
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
);
await screen.findByText('NAMESPACE copied to clipboard');
},
play: copyPodMetadata,
};
/**
@@ -122,19 +106,7 @@ export const TooltipsInDetailsDrawer: StoryObj<PodsArgs> = {
*/
export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId(
'k8s-table-options-button',
{},
{ timeout: 10000 },
),
);
await screen.findByText('Columns');
},
play: openTableOptions,
};
/**
@@ -147,16 +119,11 @@ const heldViewport = { sbshot: { viewport: { height: 1200 } } };
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.node.name');
},
play: suggestFilterKeys,
};
/** The pod filter on a namespace: the namespaces the pods run in. */
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
},
play: suggestFilterValues,
};

View File

@@ -0,0 +1,97 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import type { PodsArgs } from '../Kubernetes.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PodsArgs;
}
export const openDrawerLogsTab = async ({
args,
}: PlayContext): Promise<void> => {
if (!args.drawer || args.dataState !== 'loaded' || args.telemetryRows === 0) {
return;
}
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(await drawer.findByText('Logs', {}, { timeout: 10000 }));
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
};
export const copyPodName = async ({ args }: PlayContext): Promise<void> => {
if (!args.drawer) {
return;
}
await userEvent.click(
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
);
await screen.findByText('Pod name copied to clipboard');
};
export const copyPodMetadata = async ({ args }: PlayContext): Promise<void> => {
if (!args.drawer || args.dataState !== 'loaded') {
return;
}
await userEvent.click(
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
);
await screen.findByText('NAMESPACE copied to clipboard');
};
export const openTableOptions = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
// The drawer covers the list, so its options are out of reach.
if (args.drawer) {
return;
}
await userEvent.click(
await within(canvasElement).findByTestId(
'k8s-table-options-button',
{},
{ timeout: 10000 },
),
);
await screen.findByText('Columns');
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (args.dataState !== 'loaded') {
return;
}
await openKeySuggestions(canvasElement, 'k8s.node.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (args.dataState !== 'loaded') {
return;
}
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
};

View File

@@ -25,6 +25,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
RESPONSE_STATES,
type ResponseState,
@@ -312,3 +313,5 @@ export const logsMocks = defineStoryMocks({
);
},
});
export type LogsArgs = PageStoryArgs<typeof logsMocks>;

View File

@@ -1,23 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
blurFilter,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
findSuggestion,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { logsMocks } from './LogsModulePage.stories.mocks';
import { type LogsArgs, logsMocks } from './LogsModulePage.stories.mocks';
import {
completeFilter,
copyLog,
copyLogDetailsLink,
copyLogLinkFromRow,
dirtyQuickFiltersSettings,
openLogContext,
openLogDetails,
openLogDetailsMenu,
openQuickFiltersSettings,
showFilterSyntaxError,
stepToNextLog,
suggestConjunctions,
suggestFilterKeys,
suggestFilterValues,
suggestInListValues,
suggestInNestedGroup,
suggestNegatedOperators,
suggestNoValues,
suggestNumberOperators,
suggestOperators,
suggestPartialKey,
suggestRecentSearches,
} from './__story_mockdata__/logs.play';
import LogsModulePage from '../LogsModulePage';
type LogsArgs = PageStoryArgs<typeof logsMocks>;
const pageStory = storyMocks(logsMocks, { layout: 'app' });
/**
@@ -38,42 +50,6 @@ export default meta;
type Story = StoryObj<LogsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
/** Opening a log is what the drawer is for, and the first row will do. */
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
// The list hangs its click handler off the cells rather than the row, so a
// click on the row itself opens nothing.
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.click(timestamp);
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
};
/**
* The explorer on its list view: quick filters down the left, the frequency
* chart over a page of log lines across every severity, and the saved views
@@ -148,30 +124,17 @@ export const JsonBodyFieldsRaw: Story = {
/** One log opened up: its own fields, then the resource that sent it. */
export const LogDetails: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
},
play: openLogDetails,
};
/** The logs either side of the one that was opened, on the same filters. */
export const LogDetailsContext: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByText('Context', undefined, untilLoaded),
);
// The tab queries the logs either side before it can render them.
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
},
play: openLogContext,
};
/** The log detail drawer with its header actions menu open. */
export const LogDetailsMenuOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await screen.findByText('Copy link to log');
},
play: openLogDetailsMenu,
};
/** A response warning shown beside the query actions. */
@@ -196,17 +159,6 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
@@ -232,76 +184,48 @@ export const QuickFiltersSettingsWithBanner: Story = {
*/
export const Tooltips: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
);
},
play: stepToNextLog,
};
/** The filter focused before anything is typed: every key the logs carry. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'severity_text');
},
play: suggestFilterKeys,
};
/** A partial key, with the keys that still match and the typed part marked. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
},
play: suggestPartialKey,
};
/** A string key followed by a space: the operators a string compares with. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
},
play: suggestOperators,
};
/** A number key puts the range comparisons first. */
export const FilterNumberOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
},
play: suggestNumberOperators,
};
/** `NOT` after a key narrows the list to the operators it can negate. */
export const FilterNegatedOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
},
play: suggestNegatedOperators,
};
/** A key and an operator: the values the key holds, fetched for it. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/** Values still being fetched for the key. */
export const FilterValuesLoading: Story = {
args: { filterValues: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Loading suggestions');
},
play: suggestFilterValues,
};
/** A key the backend holds no values for, such as the free-text body. */
export const FilterNoValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
},
play: suggestNoValues,
};
/** The values request failed. */
@@ -309,120 +233,55 @@ export const FilterValuesError: Story = {
args: { filterValues: 'error' },
// The values request deliberately fails.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Error loading suggestions');
},
play: suggestFilterValues,
};
/** Inside an `IN` list, after the first value: the rest of the values. */
export const FilterInList: Story = {
// The editor logs a TypeError while the list is open, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
},
play: suggestInListValues,
};
/** A complete condition: the conjunctions that start the next one. */
export const FilterConjunctionSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
},
play: suggestConjunctions,
};
/** Inside an opened group: keys, another group and `NOT`. */
export const FilterNestedGroup: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
},
play: suggestInNestedGroup,
};
/** A long valid expression mixing operators, left for the next run. */
export const FilterComplete: Story = {
// The editor logs a TypeError while the `IN` list is typed, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
},
play: completeFilter,
};
/** An incomplete expression after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'service.name = ');
},
play: showFilterSyntaxError,
};
/** Filters run before, offered above the key suggestions. */
export const FilterRecentSearches: Story = {
args: { recentFilters: 3 },
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
},
play: suggestRecentSearches,
};
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.hover(timestamp);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLogLinkFromRow,
};
/** "Copy log" in the log detail drawer's actions menu. */
export const LogCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLog,
};
/** "Copy link to log" in the log detail drawer's actions menu. */
export const LogDetailsLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy link to log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
play: copyLogDetailsLink,
};

View File

@@ -0,0 +1,319 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
blurFilter,
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { grantsAll } from '@/storybook/access/access';
import type { LogsArgs } from '../LogsModulePage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: LogsArgs;
}
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const VALUE_SUGGESTIONS = {
loaded: 'checkout',
loading: 'Loading suggestions',
error: 'Error loading suggestions',
} satisfies Record<LogsArgs['filterValues'], string>;
/** The rows a play opens exist only in the list view's table format. */
const listsLogRows = (args: LogsArgs, count = 1): boolean =>
args.view === 'list' &&
args.format === 'table' &&
args.dataState === 'loaded' &&
args.logs >= count;
/** The settings control stays enabled when the check itself fails. */
const canManageQuickFilters = (args: LogsArgs): boolean =>
args.filtersPanel &&
(args.authzState === 'error' ||
grantsAll(args, ...QuickFilterManagePermissions));
/** Runs `drive` once the filter has its keys, which come with the page's data. */
const onFilter =
(drive: (canvasElement: HTMLElement, args: LogsArgs) => Promise<void>) =>
async ({ canvasElement, args }: PlayContext): Promise<void> => {
if (args.dataState === 'loaded') {
await drive(canvasElement, args);
}
};
/** Opening a log is what the drawer is for, and the first row will do. */
const openFirstLog = async (canvasElement: HTMLElement): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
// The list hangs its click handler off the cells rather than the row, so a
// click on the row itself opens nothing.
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.click(timestamp);
await screen.findByTestId('log-detail-drawer', undefined, untilLoaded);
};
const openLogMenuItem = async (
canvasElement: HTMLElement,
item: string,
): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText(item));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
};
export const openLogDetails = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
};
export const openLogContext = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByText('Context', undefined, untilLoaded),
);
// The tab queries the logs either side before it can render them.
await screen.findAllByText(/cache lookup for cart/, undefined, untilLoaded);
};
export const openLogDetailsMenu = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await screen.findByText('Copy link to log');
};
export const openQuickFiltersSettings = async ({
args,
}: PlayContext): Promise<void> => {
if (!canManageQuickFilters(args)) {
return;
}
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(async () => {
const settings = screen.getByTestId('settings-icon-container');
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
export const dirtyQuickFiltersSettings = async (
context: PlayContext,
): Promise<void> => {
const { args } = context;
if (
!canManageQuickFilters(args) ||
args.dataState !== 'loaded' ||
args.quickFilters === 0
) {
return;
}
await openQuickFiltersSettings(context);
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
export const stepToNextLog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args, 2)) {
return;
}
await openFirstLog(canvasElement);
await userEvent.click(
await screen.findByTestId('log-details-header-next', undefined, untilLoaded),
);
};
export const suggestFilterKeys = onFilter((canvasElement) =>
openKeySuggestions(canvasElement, 'severity_text'),
);
export const suggestPartialKey = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
});
export const suggestOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
});
export const suggestNumberOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
});
export const suggestNegatedOperators = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
});
export const suggestFilterValues = onFilter(async (canvasElement, args) => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, VALUE_SUGGESTIONS[args.filterValues]);
});
export const suggestNoValues = onFilter(async (canvasElement, args) => {
if (args.filterValues !== 'loaded') {
return;
}
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
});
export const suggestInListValues = onFilter(async (canvasElement, args) => {
if (args.filterValues !== 'loaded') {
return;
}
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
});
export const suggestConjunctions = onFilter(async (canvasElement) => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
});
export const suggestInNestedGroup = onFilter((canvasElement) =>
typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT'),
);
export const completeFilter = onFilter(async (canvasElement) => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
});
export const showFilterSyntaxError = onFilter((canvasElement) =>
showFilterErrors(canvasElement, 'service.name = '),
);
export const suggestRecentSearches = onFilter(async (canvasElement, args) => {
if (args.recentFilters === 0) {
return;
}
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
});
export const copyLogLinkFromRow = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.hover(timestamp);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
};
export const copyLog = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openLogMenuItem(canvasElement, 'Copy log');
};
export const copyLogDetailsLink = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!listsLogRows(args)) {
return;
}
await openLogMenuItem(canvasElement, 'Copy link to log');
};

View File

@@ -33,6 +33,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
import {
fieldKeysResponse,
@@ -553,6 +554,8 @@ export const metricsMocks = defineStoryMocks({
},
});
export type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
export const saveUnit = async (
canvasElement: HTMLElement,
expected: string | false,

View File

@@ -1,19 +1,19 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
import {
type MetricsArgs,
metricsMocks,
} from './MetricsExplorerPage.stories.mocks';
import {
openDashboardsMenu,
saveMetricUnit,
suggestFilterKeys,
suggestFilterValues,
} from './__story_mockdata__/metricsExplorer.play';
import MetricsExplorerPage from '../MetricsExplorerPage';
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
const pageStory = storyMocks(metricsMocks, { layout: 'app' });
/**
@@ -103,31 +103,17 @@ export const Tooltips: Story = {
*/
export const MetricDetailsDashboardsMenu: Story = {
args: { drawer: 'details' },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId(
'metric-dashboards-popover',
{},
{ timeout: 10000 },
),
);
await screen.findByRole('menu');
},
play: openDashboardsMenu,
};
/** The summary's metric search focused: the attributes metrics can be found by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
},
play: suggestFilterKeys,
};
/** The metric search on an attribute and an operator: the values it holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/**
@@ -136,14 +122,7 @@ export const FilterValueSuggestions: Story = {
*/
export const UnitSavedToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset' },
play: async ({ canvasElement, args }): Promise<void> => {
await saveUnit(
canvasElement,
args.unitSave === 'success'
? 'Unit saved successfully'
: 'Failed to save unit',
);
},
play: saveMetricUnit,
};
/** The same save with the metadata write failing. */
@@ -151,7 +130,5 @@ export const UnitSaveErrorToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await saveUnit(canvasElement, 'Failed to save unit');
},
play: saveMetricUnit,
};

View File

@@ -0,0 +1,90 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import {
type MetricsArgs,
saveUnit,
} from '../MetricsExplorerPage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: MetricsArgs;
}
const showsMetricSearch = (args: MetricsArgs): boolean =>
args.dataState === 'loaded' && args.tab !== 'volume-control';
export const openDashboardsMenu = async ({
args,
}: PlayContext): Promise<void> => {
if (
args.tab !== 'summary' ||
args.drawer !== 'details' ||
args.dataState !== 'loaded' ||
args.relatedAssets === 0
) {
return;
}
await userEvent.click(
await screen.findByTestId(
'metric-dashboards-popover',
{},
{ timeout: 10000 },
),
);
await screen.findByRole('menu');
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsMetricSearch(args)) {
return;
}
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsMetricSearch(args)) {
return;
}
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
};
export const saveMetricUnit = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (
args.tab !== 'explorer' ||
args.dataState !== 'loaded' ||
args.metricUnit !== 'unset' ||
args.explorerMetrics !== 1
) {
return;
}
await saveUnit(
canvasElement,
args.unitSave === 'success'
? 'Unit saved successfully'
: 'Failed to save unit',
);
};

View File

@@ -18,6 +18,7 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
queryRangeV5RawResponse,
@@ -354,3 +355,5 @@ export const tracesMocks = defineStoryMocks({
],
config: (values) => ({ route: tracesRoute(values) }),
});
export type TracesArgs = PageStoryArgs<typeof tracesMocks>;

View File

@@ -1,21 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { tracesMocks } from './TracesModulePage.stories.mocks';
import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
import {
dirtyQuickFiltersSettings,
loadNextListPage,
openQuickFiltersSettings,
showRootSpansNextPage,
suggestBooleanValues,
suggestFilterKeys,
suggestFilterValues,
suggestResourceKeys,
showFilterSyntaxError,
} from './__story_mockdata__/traces.play';
import TracesModulePage from '../TracesModulePage';
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
const pageStory = storyMocks(tracesMocks, { layout: 'app' });
/**
@@ -34,28 +35,8 @@ const meta = {
export default meta;
/** The page fetches before it renders its filters, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
type Story = StoryObj<TracesArgs>;
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
/**
* The Explorer tab on a full window of spans: the org's quick filters down the
* left, the query builder and the view switcher above, and the span list with
@@ -77,35 +58,13 @@ export const RootSpans: Story = {
*/
export const RootSpansNextPage: Story = {
args: { view: ExplorerViews.TRACE, spans: 50 },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
);
const pager = screen.getByLabelText('pagination');
await expect(
await within(pager).findByText('3', undefined, untilLoaded),
).toBeVisible();
},
play: showRootSpansNextPage,
};
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
export const ListNextPage: Story = {
args: { spans: 150 },
play: async ({ canvasElement }): Promise<void> => {
// Scrolls again on every attempt, since each loaded page moves the end.
await waitFor(async (): Promise<void> => {
const scroller = canvasElement.querySelector<HTMLElement>(
'[data-virtuoso-scroller]',
);
await expect(scroller).not.toBeNull();
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
await expect(
canvasElement.querySelector('tr[data-index="120"]'),
).not.toBeNull();
}, untilLoaded);
},
play: loadNextListPage,
};
/** The charted view of the same trace query. */
@@ -154,17 +113,6 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
@@ -183,38 +131,25 @@ export const QuickFiltersSettingsWithBanner: Story = {
/** The filter focused before anything is typed: span and resource keys together. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'status_code_string');
},
play: suggestFilterKeys,
};
/** A context prefix: only the keys that live on the resource. */
export const FilterResourceKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
},
play: suggestResourceKeys,
};
/** A key and an operator: the services the spans came from. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
play: suggestFilterValues,
};
/** A boolean key offers its two values. */
export const FilterBooleanValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
},
play: suggestBooleanValues,
};
/** A dangling conjunction after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'has_error = true AND');
},
play: showFilterSyntaxError,
};

View File

@@ -0,0 +1,191 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { QuickFilterManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { grantsAll } from '@/storybook/access/access';
import type { TracesArgs } from '../TracesModulePage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: TracesArgs;
}
/** The page fetches before it renders its filters, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LIST_PAGE_SIZE = 100;
const TRACE_PAGE_SIZE = 20;
const showsFilter = (args: TracesArgs): boolean =>
args.dataState === 'loaded' && args.tab !== 'funnels';
const showsView = (args: TracesArgs, view: ExplorerViews): boolean =>
args.tab === 'explorer' && args.view === view && args.dataState === 'loaded';
/** The settings control stays enabled when the check itself fails. */
const canManageQuickFilters = (args: TracesArgs): boolean =>
args.tab === 'explorer' &&
(args.authzState === 'error' ||
grantsAll(args, ...QuickFilterManagePermissions));
export const showRootSpansNextPage = async ({
args,
}: PlayContext): Promise<void> => {
// The pager offers page 3 once page 2 comes back full.
if (
!showsView(args, ExplorerViews.TRACE) ||
args.spans < 2 * TRACE_PAGE_SIZE
) {
return;
}
await userEvent.click(
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
);
const pager = screen.getByLabelText('pagination');
await expect(
await within(pager).findByText('3', undefined, untilLoaded),
).toBeVisible();
};
export const loadNextListPage = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsView(args, ExplorerViews.LIST) || args.spans <= LIST_PAGE_SIZE) {
return;
}
// Scrolls again on every attempt, since each loaded page moves the end.
await waitFor(async (): Promise<void> => {
const scroller = canvasElement.querySelector<HTMLElement>(
'[data-virtuoso-scroller]',
);
await expect(scroller).not.toBeNull();
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
const indexes = Array.from(
canvasElement.querySelectorAll<HTMLElement>('tr[data-index]'),
(row) => Number(row.dataset.index),
);
await expect(Math.max(...indexes)).toBeGreaterThanOrEqual(LIST_PAGE_SIZE);
}, untilLoaded);
};
export const openQuickFiltersSettings = async ({
args,
}: PlayContext): Promise<void> => {
if (!canManageQuickFilters(args)) {
return;
}
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(async () => {
const settings = screen.getByTestId('settings-icon-container');
await expect(settings).not.toHaveAttribute('aria-disabled', 'true');
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters');
};
export const dirtyQuickFiltersSettings = async (
context: PlayContext,
): Promise<void> => {
const { args } = context;
if (
!canManageQuickFilters(args) ||
args.dataState !== 'loaded' ||
args.quickFilters === 0
) {
return;
}
await openQuickFiltersSettings(context);
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
export const suggestFilterKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await openKeySuggestions(canvasElement, 'status_code_string');
};
export const suggestResourceKeys = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
};
export const suggestFilterValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
};
export const suggestBooleanValues = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
};
export const showFilterSyntaxError = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!showsFilter(args)) {
return;
}
await showFilterErrors(canvasElement, 'has_error = true AND');
};

View File

@@ -949,7 +949,7 @@ export function QueryBuilderProvider({
searchParams?: Record<string, unknown>,
redirectingUrl?: (typeof ROUTES)[keyof typeof ROUTES],
shouldNotStringify?: boolean,
newTab?: boolean,
navigateOptions?: { newTab?: boolean; replace?: boolean },
) => {
const queryType =
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
@@ -1001,14 +1001,18 @@ export function QueryBuilderProvider({
);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>
Object.keys(searchParams).forEach((param) => {
if (searchParams[param] === null) {
urlQuery.delete(param);
return;
}
urlQuery.set(
param,
shouldNotStringify
? (searchParams[param] as string)
: JSON.stringify(searchParams[param]),
),
);
);
});
}
// Remove Hidden Filters from URL query parameters on query change
urlQuery.delete(QueryParams.activeLogId);
@@ -1017,7 +1021,7 @@ export function QueryBuilderProvider({
? `${redirectingUrl}?${urlQuery}`
: `${location.pathname}?${urlQuery}`;
safeNavigate(generatedUrl, { newTab });
safeNavigate(generatedUrl, navigateOptions);
},
[location.pathname, safeNavigate],
);

View File

@@ -0,0 +1,53 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
describe('redirectWithQueryBuilderData search params', () => {
it('removes params passed as null and JSON-encodes the rest', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders
initialRoute={`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`}
>
{children}
</AllTheProviders>
),
});
act(() => {
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
[QueryParams.relativeTime]: null,
[QueryParams.startTime]: 1000,
});
});
const [url] = safeNavigateMock.mock.lastCall ?? [];
expect(url).toContain(`${QueryParams.startTime}=1000`);
expect(url).not.toContain(`${QueryParams.relativeTime}=`);
expect(url).toContain(`${QueryParams.viewName}=kept`);
});
it('passes navigate options to safeNavigate', () => {
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute="/unconstrained">{children}</AllTheProviders>
),
});
act(() => {
result.current.redirectWithQueryBuilderData(
initialQueriesMap.logs,
{ [QueryParams.startTime]: 1000 },
undefined,
undefined,
{ replace: false },
);
});
expect(safeNavigateMock).toHaveBeenLastCalledWith(expect.any(String), {
replace: false,
});
});
});

View File

@@ -116,6 +116,10 @@ Storybook fills the seams with:
because elapsed-time code reads it — `lodash.debounce` compares two readings
to decide its trailing call is due, and a frozen one leaves every debounced
input in the app filtering nothing. `?storyClock=live` opts out.
- A speech recognizer in place of the browser's, also in
`.storybook/preview-head.html`, because Storybook has no microphone. It
starts listening on `start()` and hears only what a `play` passes to
`webkitSpeechRecognition.listening.hear(text)`.
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it

View File

@@ -6,12 +6,16 @@ import {
IsViewerPermission,
} from 'lib/authz/hooks/useAuthZ/legacy';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import {
formatPermission,
gettableTransactionToPermission,
permissionToTransactionDto,
} from 'lib/authz/hooks/useAuthZ/utils';
import { ROLES, USER_ROLES } from 'types/roles';
import type { ResponseState } from '../runtime/responseState';
/**
* `relation:kind` for every verb the backend allows on a resource, from the
* generated permission catalogue, so a resource added there shows up here
@@ -159,3 +163,23 @@ export const accessFor = (
legacyRole: deriveLegacyRole(permissions),
};
};
export interface AccessArgs {
access: AccessPreset;
permissions: readonly string[];
revoked: readonly string[];
authzState: ResponseState;
}
/** Whether `authz/check`, answered from a story's access controls, passes every one of `checks`. */
export const grantsAll = (
args: AccessArgs,
...checks: BrandedPermission[]
): boolean => {
const grant = accessFor(args.access, args.permissions, args.revoked);
return (
args.authzState === 'loaded' &&
checks.every((check) => grant.allows(permissionToTransactionDto(check)))
);
};

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, within } from 'storybook/test';
import { screen, userEvent } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
@@ -72,19 +72,6 @@ export const NozAwaitingYou: Story = {
args: { noz: true, nozAwaiting: 2, support: 'pylon' },
};
/** The Noz chat opened from Ask Noz, floating above the strip; its header drags it. */
export const NozPanel: Story = {
args: { noz: true, support: 'pylon' },
play: async (): Promise<void> => {
await userEvent.click(
within(
await screen.findByTestId('bottom-strip-ask-noz', undefined, untilLoaded),
).getByRole('button'),
);
await screen.findByRole('button', { name: 'Close panel' });
},
};
/** Every tooltip the strip carries, held open. */
export const Tooltips: Story = {
args: { noz: true, support: 'pylon', tooltipsOpen: true },

View File

@@ -3,7 +3,6 @@ import { AI_API_PATH } from 'api/AIAPIInstance';
import type { ThreadListResponse } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import type { ConversationStreamState } from 'container/AIAssistant/types';
import { chipsResponse } from 'pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant';
import { countControl, toggleControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
@@ -58,10 +57,6 @@ export const nozMocks = defineStoryMocks({
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/threads`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(noThreads)),
),
rest.get(
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/empty-state/chips`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(chipsResponse())),
),
]
: [],
effect: ({ nozAwaiting }) => {
@@ -77,9 +72,6 @@ export const nozMocks = defineStoryMocks({
for (let index = 0; index < nozAwaiting; index += 1) {
state.streams[`${AWAITING_PREFIX}${index}`] = awaitingStream();
}
// Persisted, so a panel one story opened would hide Ask Noz in the next.
state.isDrawerOpen = false;
});
},
});

View File

@@ -36,7 +36,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {

View File

@@ -312,7 +312,7 @@ export type QueryBuilderContextType = {
searchParams?: Record<string, unknown>,
redirectToUrl?: (typeof ROUTES)[keyof typeof ROUTES],
shallStringify?: boolean,
newTab?: boolean,
navigateOptions?: { newTab?: boolean; replace?: boolean },
) => void;
handleRunQuery: () => void;
resetQuery: (newCurrentQuery?: QueryState) => void;

28
go.mod
View File

@@ -5,9 +5,9 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.5.6
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/ClickHouse/clickhouse-go/v2 v2.47.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/clickhouse-go-mock v0.15.1
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
@@ -75,13 +75,13 @@ require (
go.opentelemetry.io/collector/pdata v1.54.0
go.opentelemetry.io/contrib/config v0.10.0
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.55.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.58.0
@@ -173,11 +173,11 @@ require (
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/ClickHouse/ch-go v0.71.0
github.com/ClickHouse/ch-go v0.73.0
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/beevik/etree v1.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -190,7 +190,7 @@ require (
github.com/dennwc/varint v1.0.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/elastic/lunes v0.2.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
@@ -233,7 +233,7 @@ require (
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/memberlist v0.5.4 // indirect
@@ -254,7 +254,7 @@ require (
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-syslog/v4 v4.3.0 // indirect
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
@@ -275,9 +275,9 @@ require (
github.com/open-feature/go-sdk v1.17.0
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.144.0 // indirect
github.com/openfga/openfga v1.14.1
github.com/paulmach/orb v0.12.0 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pressly/goose/v3 v3.27.0 // indirect
@@ -293,7 +293,7 @@ require (
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/backo-go v1.0.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
@@ -308,8 +308,8 @@ require (
github.com/swaggest/openapi-go v0.2.60
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/valyala/fastjson v1.6.7 // indirect
github.com/vjeantet/grok v1.0.1 // indirect

78
go.sum
View File

@@ -79,10 +79,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M=
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
github.com/ClickHouse/clickhouse-go/v2 v2.47.0 h1:ZDAzrnKSOPTIsm4tdUNfrii2yc8dk4SVRLC77BR7Z5Q=
github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
@@ -94,8 +94,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/SigNoz/clickhouse-go-mock v0.14.0 h1:yYoGMJk0UDVvLCk12dover/zoRIuznW3BfoqUhMoJSY=
github.com/SigNoz/clickhouse-go-mock v0.14.0/go.mod h1:tXmSXVLWz7N/yVLCianszmNIN4cHozXvwEXCOLzzqzI=
github.com/SigNoz/clickhouse-go-mock v0.15.1 h1:ifoh/3Jx6+Zt4ptuSeUnZrOW5tVy/WSgzGpAh+w4NaY=
github.com/SigNoz/clickhouse-go-mock v0.15.1/go.mod h1:r1qiBP8tA1KG7bmuCg0wrt0YxBMWylL7FPTBbx9wTTs=
github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72gAA=
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
@@ -116,8 +116,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
@@ -255,15 +255,15 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
@@ -596,8 +596,8 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@@ -676,7 +676,6 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -712,8 +711,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0=
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
@@ -768,10 +767,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -780,7 +779,6 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -842,9 +840,8 @@ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYr
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
@@ -855,8 +852,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/perses/spec v0.1.2 h1:yGoygcR3ZusuGDCmRMwsVXCMvMwi1qZndKV6NYNreEw=
github.com/perses/spec v0.1.2/go.mod h1:NoGI5jmGwRdkdPgyYSZJTBL4/Py+dqIPKS2QV8NOvGE=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -965,8 +962,8 @@ github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs=
@@ -1044,14 +1041,13 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@@ -1093,12 +1089,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
@@ -1120,7 +1112,6 @@ go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dY
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -1259,8 +1250,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0/go.mod h1:34csimR1lUhdT5HH4Rii9aKPrvBcnFRwxLwcevsU+Kk=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0 h1:c9r/G1CSw4dPI1jaNNG9RnQP+q4SvZnHciDQJVIvchU=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/contrib/otelconf v0.18.0 h1:ciF2Gf00BWs0DnexKFZXcxg9kJ8r3SUW1LOzW3CsKA8=
go.opentelemetry.io/contrib/otelconf v0.18.0/go.mod h1:FcP7k+JLwBLdOxS6qY6VQ/4b5VBntI6L6o80IMwhAeI=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
@@ -1332,8 +1323,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -1350,7 +1341,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -14,124 +14,343 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v3/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRuleByID",
Tags: []string{"rules"},
Summary: "Get alert rule by ID",
Description: "This endpoint returns an alert rule by ID",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateRule",
Tags: []string{"rules"},
Summary: "Create alert rule",
Description: "This endpoint creates a new alert rule",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},
Summary: "Update alert rule",
Description: "This endpoint updates an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
ID: "DeleteRuleByID",
Tags: []string{"rules"},
Summary: "Delete alert rule",
Description: "This endpoint deletes an alert rule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
Optional: true,
},
),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
ID: "PatchRuleByID",
Tags: []string{"rules"},
Summary: "Patch alert rule",
Description: "This endpoint applies a partial update to an alert rule by ID",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rules/test", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "TestRule",
Tags: []string{"rules"},
Summary: "Test alert rule",
Description: "This endpoint fires a test notification for the given rule definition",
Request: new(ruletypes.PostableRule),
RequestContentType: "application/json",
RequestExamples: postableRuleExamples(),
Response: new(ruletypes.GettableTestRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes(append([]string{
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}, telemetryReadScopes()...)),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRule,
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: ruler.NewRuleBodyTelemetryResources(),
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -251,7 +251,6 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,

View File

@@ -5,28 +5,23 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPostableRuleExamplesValidate verifies every example payload returned by
// postableRuleExamples() round-trips through PostableRule.UnmarshalJSON and
// postableRuleExamples() round-trips through PostableRule.DecodeStrict and
// passes Validate(). If an example drifts from the runtime contract this
// breaks loudly so the spec doesn't ship invalid payloads to users.
func TestPostableRuleExamplesValidate(t *testing.T) {
for _, example := range postableRuleExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
if err != nil {
t.Fatalf("marshal example: %v", err)
}
require.NoError(t, err)
var rule ruletypes.PostableRule
if err := json.Unmarshal(raw, &rule); err != nil {
t.Fatalf("unmarshal: %v\npayload: %s", err, raw)
}
if err := rule.Validate(); err != nil {
t.Fatalf("Validate: %v\npayload: %s", err, raw)
}
require.NoError(t, rule.DecodeStrict(string(raw)), "payload: %s", raw)
assert.NoError(t, rule.Validate(), "payload: %s", raw)
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
)
@@ -22,7 +23,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -39,7 +45,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -56,7 +67,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -73,7 +89,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodDelete).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
@@ -90,7 +111,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -110,7 +136,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -127,7 +158,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceSession,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -68,7 +68,13 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userIDExtractor(),
Selector: coretypes.IDSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -128,7 +134,13 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userIDExtractor(),
Selector: coretypes.IDSelector,
}))).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -294,7 +306,12 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -311,7 +328,12 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -328,7 +350,12 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnprocessableEntity},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -345,7 +372,12 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -493,6 +525,21 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return nil
}
func (provider *provider) userIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
return claims.UserID, nil
})
}
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {

View File

@@ -109,6 +109,8 @@ type TelemetryResourceDef struct {
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
// Optional skips the check instead of failing when the request carries no telemetry resources.
Optional bool
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -116,6 +118,9 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 && def.Optional {
return nil
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,

View File

@@ -69,3 +69,43 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
testCases := []struct {
name string
optional bool
wantLen int
wantErr bool
}{
{
name: "Required_Errors",
optional: false,
wantLen: 1,
wantErr: true,
},
{
name: "Optional_ResolvesNothing",
optional: true,
wantLen: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
def := TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
Optional: testCase.optional,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
require.Len(t, resolved, testCase.wantLen)
if testCase.wantErr {
assert.Error(t, resolved[0].Err())
}
})
}
}

View File

@@ -457,6 +457,13 @@ func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
result, _, err := q.executeSkippingRaw(ctx, query, args, 0)
return result, err
}
// executeSkippingRaw is executeWithContext for a raw query that drops its first
// skip rows undecoded. It returns how many rows were skipped.
func (q *builderQuery[T]) executeSkippingRaw(ctx context.Context, query string, args []any, skip int) (*qbtypes.Result, int, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: q.spec.Signal.StringValue(),
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
@@ -475,19 +482,19 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "Query timed out").
return nil, 0, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "Query timed out").
WithAdditional("Try refining your search by adding relevant resource attributes filtering")
}
if !errors.Is(err, context.Canceled) {
return nil, errors.Newf(
return nil, 0, errors.Newf(
errors.TypeInternal,
errors.CodeInternal,
"Something went wrong on our end. It's not you, it's us. Our team is notified about it. Reach out to support if issue persists.",
)
}
return nil, err
return nil, 0, err
}
defer rows.Close()
@@ -501,9 +508,15 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
kind = qbtypes.RequestTypeTimeSeries
}
payload, err := consume(rows, kind, queryWindow, q.spec.StepInterval, q.spec.Name)
var payload any
skipped := 0
if skip > 0 {
payload, skipped, err = readAsRawSkipping(rows, q.spec.Name, skip)
} else {
payload, err = consume(rows, kind, queryWindow, q.spec.StepInterval, q.spec.Name)
}
if err != nil {
return nil, err
return nil, 0, err
}
// TODO: This should move to readAsRaw function in consume.go but for now we are keeping it here since it's only relevant for traces
@@ -523,7 +536,7 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
BytesScanned: totalBytes,
DurationMS: uint64(elapsed.Milliseconds()),
},
}, nil
}, skipped, nil
}
func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Result, error) {
@@ -623,7 +636,7 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
}
}
// Execute with proper context for partial value detection
res, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
res, skipped, err := q.executeSkippingRaw(ctx, stmt.Query, stmt.Args, offsetLeft)
if err != nil {
return nil, err
}
@@ -631,13 +644,10 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes += res.Stats.BytesScanned
rawRows := res.Value.(*qbtypes.RawData).Rows
need -= len(rawRows)
need -= skipped + len(rawRows)
offsetLeft -= skipped
for _, rr := range rawRows {
if offsetLeft > 0 { // client-requested initial offset
offsetLeft--
continue
}
rows = append(rows, rr)
if len(rows) >= reqLimit { // page filled
break

View File

@@ -521,13 +521,26 @@ func derefValue(v any) any {
}
func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
data, _, err := readAsRawSkipping(rows, queryName, 0)
return data, err
}
// readAsRawSkipping advances past the first skip rows without scanning them, so
// rows dropped for an offset are never decoded. It returns how many were skipped.
func readAsRawSkipping(rows driver.Rows, queryName string, skip int) (*qbtypes.RawData, int, error) {
colNames := rows.Columns()
colTypes := rows.ColumnTypes()
colCnt := len(colNames)
var outRows []*qbtypes.RawRow
skipped := 0
for rows.Next() {
if skipped < skip {
skipped++
continue
}
// fresh copy of the scan slice (otherwise the driver reuses pointers)
scan := make([]any, colCnt)
for i := range colTypes {
@@ -535,7 +548,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}
if err := rows.Scan(scan...); err != nil {
return nil, err
return nil, 0, err
}
rr := qbtypes.RawRow{
@@ -567,13 +580,13 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
outRows = append(outRows, &rr)
}
if err := rows.Err(); err != nil {
return nil, err
return nil, 0, err
}
return &qbtypes.RawData{
QueryName: queryName,
Rows: outRows,
}, nil
}, skipped, nil
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into

View File

@@ -5,9 +5,11 @@ import (
"testing"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
@@ -254,3 +256,41 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
})
}
}
func TestReadAsRawSkipping(t *testing.T) {
testCases := []struct {
name string
values []string
skip int
wantSkipped int
wantIDs []string
}{
{name: "NoSkip", values: []string{"a", "b", "c"}, skip: 0, wantSkipped: 0, wantIDs: []string{"a", "b", "c"}},
{name: "SkipInsideResult", values: []string{"d", "e", "f", "g"}, skip: 2, wantSkipped: 2, wantIDs: []string{"f", "g"}},
{name: "SkipWholeResult", values: []string{"h", "i"}, skip: 2, wantSkipped: 2, wantIDs: nil},
{name: "SkipPastResult", values: []string{"j"}, skip: 5, wantSkipped: 1, wantIDs: nil},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
values := make([][]any, len(testCase.values))
for i, value := range testCase.values {
values[i] = []any{value}
}
rows := cmock.NewRows([]cmock.ColumnType{{Name: "id", Type: "String"}}, values)
for i := 0; i < testCase.skip && i < len(values); i++ {
rows.RowError(i, errors.New(errors.TypeInternal, errors.CodeInternal, "skipped row was scanned"))
}
data, skipped, err := readAsRawSkipping(rows, "A", testCase.skip)
require.NoError(t, err)
var ids []string
for _, row := range data.Rows {
ids = append(ids, row.Data["id"].(string))
}
assert.Equal(t, testCase.wantSkipped, skipped)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -1054,31 +1054,24 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err
}
storedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
if err := storedRule.DecodeStrict(ruleStr); err != nil {
storedRule, err := ruletypes.NewPatchedPostableRule([]byte(storedJSON.Data), []byte(ruleStr))
if err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
if err := storedRule.Validate(); err != nil {
return nil, err
}
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
if err := m.validateChannels(ctx, claims.OrgID, storedRule); err != nil {
return nil, err
}
// deploy or un-deploy task according to patched (new) rule state
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
return nil, err
}
newStoredJson, err := json.Marshal(&storedRule)
newStoredJson, err := json.Marshal(storedRule)
if err != nil {
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
@@ -1091,7 +1084,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
if err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to restore rule after patch failure", "task_name", taskName, errors.Attr(err))
}
return nil, err
@@ -1100,7 +1093,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// prepare http response
response := ruletypes.GettableRule{
Id: id.StringValue(),
PostableRule: storedRule,
PostableRule: *storedRule,
}
// fetch state of rule from memory

View File

@@ -5,12 +5,15 @@ import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
const (
@@ -20,6 +23,8 @@ const (
type diffSide int
type incomingChannelsFunc func(stored *ruletypes.GettableRule, body []byte) ([]string, error)
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
if err != nil {
@@ -81,6 +86,126 @@ func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.Resou
}}
}
func NewCreateRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
names, err := parseRuleChannels(ec.RequestBody)
if err != nil {
return nil, err
}
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, names)
}}
}
func NewCloneRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, stored.Channels())
}}
}
func NewReplaceRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, attachedSide)
}
func NewReplaceRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, detachedSide)
}
func NewPatchRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, attachedSide)
}
func NewPatchRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, detachedSide)
}
func newRuleChannelDiffExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler, incoming incomingChannelsFunc, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}
incomingNames, err := incoming(stored, ec.RequestBody)
if err != nil {
return nil, err
}
attached, detached := diffLinks(stored.Channels(), incomingNames)
if side == detachedSide {
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, detached)
}
return resolveKnownChannelIDs(ctx, alertmanagerService, orgID, attached)
}}
}
func parseReplacedRuleChannels(_ *ruletypes.GettableRule, body []byte) ([]string, error) {
return parseRuleChannels(body)
}
func parsePatchedRuleChannels(stored *ruletypes.GettableRule, body []byte) ([]string, error) {
patched, err := applyRulePatch(stored, body)
if err != nil {
return nil, err
}
return patched.Channels(), nil
}
func applyRulePatch(stored *ruletypes.GettableRule, body []byte) (*ruletypes.PostableRule, error) {
encoded, err := json.Marshal(stored.PostableRule)
if err != nil {
return nil, err
}
return ruletypes.NewPatchedPostableRule(encoded, body)
}
func parseRuleChannels(body []byte) ([]string, error) {
rule := new(ruletypes.PostableRule)
if err := rule.DecodeStrict(string(body)); err != nil {
return nil, err
}
return rule.Channels(), nil
}
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
}
@@ -153,6 +278,20 @@ func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager,
}}
}
// resolveKnownChannelIDs skips unknown names, the handler rejects them with a clearer error.
func resolveKnownChannelIDs(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID, names []string) ([]string, error) {
if len(names) == 0 {
return nil, nil
}
channels, err := alertmanagerService.ListChannels(ctx, orgID.StringValue())
if err != nil {
return nil, err
}
return alertmanagertypes.MapKnownChannelNamesToIDs(channels, names), nil
}
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -184,17 +323,47 @@ func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor
}
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
func NewRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
compositeQuery := gjson.GetBytes(ec.RequestBody, "condition.compositeQuery")
if !compositeQuery.Exists() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery is required")
}
body, err := json.Marshal(map[string]json.RawMessage{"compositeQuery": json.RawMessage(compositeQuery.Raw)})
if err != nil {
return nil, err
}
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
}
}
func NewOptionalRuleBodyTelemetryResources() coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
if !gjson.GetBytes(ec.RequestBody, "condition.compositeQuery").Exists() {
return nil, nil
}
return NewRuleBodyTelemetryResources()(ec)
}
}
func fetchStoredRule(ec coretypes.ExtractorContext, rulerService Ruler) (*ruletypes.GettableRule, error) {
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.GetRule(ec.Request.Context(), ruleID)
return rulerService.GetRule(ec.Request.Context(), ruleID)
}
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
if ec.Request == nil {
return nil, nil
}
stored, err := fetchStoredRule(ec, rulerService)
if err != nil {
return nil, err
}

View File

@@ -1,14 +1,20 @@
package ruler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
@@ -314,3 +320,309 @@ func TestMapFieldResources(t *testing.T) {
})
}
}
func v2RuleJSON(channels string) string {
return `{
"alert": "TestAlert",
"version": "v5",
"schemaVersion": "v2alpha1",
"ruleType": "threshold_rule",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90.0, "matchType": "1", "op": "1", "channels": ` + channels + `}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {"evalWindow": "5m", "frequency": "1m"}
},
"notificationSettings": {
"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}
}
}`
}
func TestParseRuleChannels(t *testing.T) {
testCases := []struct {
name string
body string
wantChannels []string
wantErr bool
}{
{
name: "V2ThresholdChannels_ReadDirectly",
body: v2RuleJSON(`["email"]`),
wantChannels: []string{"email"},
},
{
name: "V2EmptyChannels_Empty",
body: v2RuleJSON(`[]`),
wantChannels: nil,
},
{
name: "NotJSON_InvalidInput",
body: "not json",
wantErr: true,
},
{
name: "TruncatedJSON_InvalidInput",
body: `{"alert": `,
wantErr: true,
},
{
name: "UnknownField_InvalidInput",
body: `{"preferredChannels": ["pager"]}`,
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
channels, err := parseRuleChannels([]byte(testCase.body))
if testCase.wantErr {
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), err.Error())
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
})
}
}
func TestIncomingRuleChannels(t *testing.T) {
testCases := []struct {
name string
incoming incomingChannelsFunc
stored string
body string
wantChannels []string
wantStoredAfter []string
wantErr bool
}{
{
name: "Replaced_IgnoresStored",
incoming: parseReplacedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_OmitsCondition_StoredChannelsSurvive",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: `{"disabled": true}`,
wantChannels: []string{"slack"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_SetsChannels_StoredChannelsReplaced",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`["pager"]`),
wantChannels: []string{"pager"},
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_EmptiesChannels_NoChannelsLeft",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: v2RuleJSON(`[]`),
wantChannels: nil,
wantStoredAfter: []string{"slack"},
},
{
name: "Patched_UnknownField_Errors",
incoming: parsePatchedRuleChannels,
stored: v2RuleJSON(`["slack"]`),
body: `{"preferredChannels": ["pager"]}`,
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(testCase.stored), &stored.PostableRule))
channels, err := testCase.incoming(stored, []byte(testCase.body))
if testCase.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantChannels, channels)
assert.Equal(t, testCase.wantStoredAfter, stored.Channels())
})
}
}
func TestRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantKind coretypes.Kind
wantID string
wantErr bool
}{
{
name: "V5Body_NoFilter_MetricsWildcard",
body: v2RuleJSON(`[]`),
wantKind: coretypes.KindMetrics,
wantID: "builder_query/*",
},
{
name: "MissingCondition_Errors",
body: `{"alert": "TestAlert", "version": "v5"}`,
wantErr: true,
},
{
name: "NotJSON_Errors",
body: "not json",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, refs, 1)
assert.Equal(t, testCase.wantKind, refs[0].Resource.Kind())
assert.Equal(t, testCase.wantID, refs[0].ID)
})
}
}
func TestApplyRulePatch(t *testing.T) {
logsQuery := `{"condition": {"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "stepInterval": "5m"}}]}}}`
testCases := []struct {
name string
body string
wantSignal string
wantChannels []string
}{
{
name: "BodyWithoutCondition_KeepsStoredQueryAndChannels",
body: `{"disabled": true}`,
wantSignal: "metrics",
wantChannels: []string{"slack"},
},
{
name: "BodyWithNewQuery_ReplacesSignalKeepsThresholds",
body: logsQuery,
wantSignal: "logs",
wantChannels: []string{"slack"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stored := new(ruletypes.GettableRule)
require.NoError(t, json.Unmarshal([]byte(v2RuleJSON(`["slack"]`)), &stored.PostableRule))
patched, err := applyRulePatch(stored, []byte(testCase.body))
require.NoError(t, err)
require.NotNil(t, patched.RuleCondition)
require.NotNil(t, patched.RuleCondition.CompositeQuery)
encoded, err := json.Marshal(patched.RuleCondition.CompositeQuery)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"signal":"`+testCase.wantSignal+`"`)
assert.Equal(t, testCase.wantChannels, patched.Channels())
})
}
}
func TestOptionalRuleBodyTelemetryResources(t *testing.T) {
testCases := []struct {
name string
body string
wantRefs int
}{
{
name: "BodyWithoutCondition_NoResources",
body: `{"disabled": true}`,
wantRefs: 0,
},
{
name: "BodyWithQuery_MetricsResource",
body: v2RuleJSON(`[]`),
wantRefs: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := NewOptionalRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Len(t, refs, testCase.wantRefs)
})
}
}
func TestResolveKnownChannelIDs(t *testing.T) {
channels := []*alertmanagertypes.Channel{
{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Name: "slack", DisplayName: "slack"},
}
testCases := []struct {
name string
names []string
wantIDs []string
}{
{
name: "KnownName_Resolved",
names: []string{"slack"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "UnknownName_Skipped",
names: []string{"slack", "gone"},
wantIDs: []string{channels[0].ID.StringValue()},
},
{
name: "NoNames_NoLookup",
names: nil,
wantIDs: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
alertmanagerService := alertmanagertest.NewMockAlertmanager(t)
if len(testCase.names) > 0 {
alertmanagerService.EXPECT().ListChannels(mock.Anything, mock.Anything).Return(channels, nil)
}
ids, err := resolveKnownChannelIDs(context.Background(), alertmanagerService, valuer.GenerateUUID(), testCase.names)
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -168,6 +168,23 @@ func MapChannelNamesToIDs(channels Channels, names []string) ([]string, error) {
return ids, nil
}
// MapKnownChannelNamesToIDs skips names with no matching channel instead of failing.
func MapKnownChannelNamesToIDs(channels Channels, names []string) []string {
idByName := make(map[string]string, len(channels))
for _, channel := range channels {
idByName[channel.DisplayName] = channel.ID.StringValue()
}
ids := make([]string, 0, len(names))
for _, name := range names {
if id, ok := idByName[name]; ok {
ids = append(ids, id)
}
}
return ids
}
func NewStatsFromChannels(channels Channels) map[string]any {
stats := make(map[string]any)
for _, channel := range channels {

View File

@@ -332,3 +332,28 @@ func TestMapChannelNamesToIDs(t *testing.T) {
})
}
}
func TestMapKnownChannelNamesToIDs(t *testing.T) {
slackID := valuer.GenerateUUID()
pagerID := valuer.GenerateUUID()
channels := Channels{
{Identifiable: types.Identifiable{ID: slackID}, DisplayName: "slack-alerts"},
{Identifiable: types.Identifiable{ID: pagerID}, DisplayName: "pager-oncall"},
}
testCases := []struct {
name string
names []string
want []string
}{
{name: "AllKnown_IDsInRequestOrder", names: []string{"pager-oncall", "slack-alerts"}, want: []string{pagerID.StringValue(), slackID.StringValue()}},
{name: "UnknownName_Skipped", names: []string{"slack-alerts", "deleted-channel"}, want: []string{slackID.StringValue()}},
{name: "NoNames_Empty", names: nil, want: []string{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, MapKnownChannelNamesToIDs(channels, testCase.names))
})
}
}

View File

@@ -250,6 +250,21 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
func NewPatchedPostableRule(stored []byte, patch []byte) (*PostableRule, error) {
patched := new(PostableRule)
if err := json.Unmarshal(stored, patched); err != nil {
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
patched.RuleCondition.SelectedQuery = patched.RuleCondition.SelectedQueryName()
if err := patched.DecodeStrict(string(patch)); err != nil {
return nil, err
}
return patched, nil
}
func (r *PostableRule) DecodeStrict(ruleStr string) error {
err := json.Unmarshal([]byte(ruleStr), r)
if err == nil {

View File

@@ -114,8 +114,8 @@ def pytest_addoption(parser: pytest.Parser):
help="clickhouse version",
)
parser.addoption(
"--schema-migrator-version",
"--signoz-otel-collector-version",
action="store",
default="v0.144.9", # todo(nikhil): change to 0.144.10
help="schema migrator version",
default="v0.144.16-rc.1",
help="signoz-otel-collector version, used to run clickhouse migrations",
)

View File

@@ -990,7 +990,7 @@ def insert_reduced_metrics_to_clickhouse(
) -> None:
"""Insert reduced series into distributed_time_series_v4_reduced and 60s
buckets into the reduced samples tables. These tables exist only when
the schema migrator version includes the metrics cardinality-control
the signoz-otel-collector version includes the metrics cardinality-control
migration."""
if time_series:
conn.insert(

View File

@@ -19,37 +19,28 @@ def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-a
"""
Factory function for running schema migrations. Accepts optional
env_overrides to customize the migrator environment; the release comes
from the --schema-migrator-version option.
from the --signoz-otel-collector-version option.
"""
def create() -> None:
migrator_version = request.config.getoption("--schema-migrator-version")
collector_version = request.config.getoption("--signoz-otel-collector-version")
client = docker.from_env()
environment = dict(env_overrides) if env_overrides else {}
environment = {
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN": clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN"],
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER": "cluster",
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_REPLICATION": "true",
"SIGNOZ_OTEL_COLLECTOR_TIMEOUT": "10m",
**(env_overrides or {}),
}
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
if result["StatusCode"] != 0:
logs = container.logs().decode(encoding="utf-8")
container.remove()
print(logs)
raise RuntimeError("failed to run migrations on clickhouse")
container.remove()
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"async --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
image=f"signoz/signoz-otel-collector:{collector_version}",
entrypoint="/bin/sh",
command=[
"-c",
"/signoz-otel-collector migrate bootstrap && /signoz-otel-collector migrate sync up && /signoz-otel-collector migrate async up",
],
detach=True,
auto_remove=False,
network=network.id,

View File

@@ -1018,20 +1018,38 @@ def assert_distribution_sums_heatmap(
# we already create the evolution for resource during schema migration
# since we have to create test data around it, we need to get the evolution time
def get_resource_evolution_time(signoz: types.SigNoz, signal: str) -> datetime:
return get_column_evolution_time(signoz, signal, "resource", "resource")
# the migrator also seeds the traces attributes JSON rollout at install
def get_attribute_evolution_time(signoz: types.SigNoz, signal: str) -> datetime:
return get_column_evolution_time(signoz, signal, "attributes", "attribute")
def get_attribute_backend_now(signoz: types.SigNoz, attribute_backend: str) -> datetime:
"""The "now" a test anchors its spans and query window to. "map" sits before the traces
attributes JSON rollout, where the querier reads only the legacy maps, the same SQL as with
use_trace_attributes_json off; "json" is the wall clock, after the rollout."""
if attribute_backend == "map":
return get_attribute_evolution_time(signoz, "traces") - timedelta(hours=1)
return datetime.now(tz=UTC)
def get_column_evolution_time(signoz: types.SigNoz, signal: str, column_name: str, field_context: str) -> datetime:
result = signoz.telemetrystore.conn.query(
"""
SELECT release_time
SELECT min(release_time)
FROM signoz_metadata.distributed_column_evolution_metadata
WHERE signal = %(signal)s
AND field_context = 'resource'
AND field_context = %(field_context)s
AND field_name = '__all__'
AND column_name = 'resource'
LIMIT 1
AND column_name = %(column_name)s
HAVING count() > 0
""",
parameters={"signal": signal},
parameters={"signal": signal, "column_name": column_name, "field_context": field_context},
).result_rows
assert result, f"Expected {signal} resource evolution metadata to exist"
assert result, f"Expected {signal} {column_name} evolution metadata to exist"
release_time_ns = int(result[0][0])
return datetime.fromtimestamp(release_time_ns / 1e9, tz=UTC)
@@ -1132,7 +1150,7 @@ def generate_logs_with_corrupt_metadata() -> list[Logs]:
]
def generate_traces_with_corrupt_metadata() -> list[Traces]:
def generate_traces_with_corrupt_metadata(now: datetime | None = None) -> list[Traces]:
"""
Specifically, entries with 'id', 'timestamp', 'trace_id' and 'duration_nano' fields in metadata
"""
@@ -1143,7 +1161,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
now = (now or datetime.now(tz=UTC)).replace(second=0, microsecond=0)
return [
Traces(

View File

@@ -92,7 +92,7 @@ def create_signoz(
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__WAIT": "1s",
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__INTERVAL": "5s",
"SIGNOZ_CLOUDINTEGRATION_AGENT_VERSION": "v0.0.8",
# tests reseed the attributes rollout row between cases
# no schema metadata caching in tests
"SIGNOZ_QUERIER_SCHEMAMETADATASTORE_CACHE__TTL": "1ns",
}
| sqlstore.env

View File

@@ -986,60 +986,6 @@ def insert_traces(
)
def insert_attribute_evolution_to_clickhouse(conn, signal: str, release_time: datetime.datetime) -> None:
"""Seed the `attributes` JSON column-evolution row for a signal at release_time. Unlike the
resource row (seeded by the migrator at install), the attribute JSON rollout is install-specific
and not migrator-seeded, so tests insert it to gate map-vs-JSON resolution across a window."""
# insert_deduplicate=0: successive tests seed a byte-identical row (release_time is
# minute-aligned), and ReplicatedMergeTree would drop the re-insert as a duplicate even
# after the prior test's teardown deleted it, leaving the querier to fall back to the Map.
conn.command(
"""
INSERT INTO signoz_metadata.distributed_column_evolution_metadata
(signal, column_name, column_type, field_context, field_name, version, release_time)
SETTINGS insert_deduplicate = 0
VALUES (%(signal)s, 'attributes', 'JSON()', 'attribute', '__all__', 1, %(release_time_ns)s)
""",
parameters={"signal": signal, "release_time_ns": int(release_time.timestamp() * 1e9)},
)
@pytest.fixture(name="seed_attribute_evolution", scope="function")
def seed_attribute_evolution(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[str, datetime.datetime], None], Any]:
def _seed(signal: str, release_time: datetime.datetime) -> None:
insert_attribute_evolution_to_clickhouse(clickhouse.conn, signal, release_time)
yield _seed
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
clickhouse.conn.query(f"ALTER TABLE signoz_metadata.column_evolution_metadata ON CLUSTER '{cluster}' DELETE WHERE column_name = 'attributes' AND field_context = 'attribute' AND field_name = '__all__' SETTINGS mutations_sync = 1")
# A rollout far before any test's query window, so seeding it makes the querier read span
# attributes entirely from the native JSON column rather than straddling into the legacy Map.
ATTRIBUTE_JSON_ROLLOUT_TIME = datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC)
@pytest.fixture(name="use_attribute_backend")
def use_attribute_backend(
seed_attribute_evolution: Callable[[str, datetime.datetime], None],
) -> Callable[[str], None]:
"""Applies the physical attribute layout a test runs under. "map" leaves the querier on the
legacy attributes_{string,number,bool} maps; "json" seeds the column-evolution row so it reads
the native `attributes` JSON column instead. Pair with
@pytest.mark.parametrize("attribute_backend", ["map", "json"]) and call it once in the body;
insert_traces dual-writes both layouts, so a test that passes under both has strict Map/JSON
parity."""
def _apply(backend: str) -> None:
if backend == "json":
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
return _apply
@pytest.fixture(name="insert_top_level_operations", scope="function")
def insert_top_level_operations(
clickhouse: types.TestContainerClickhouse,

View File

@@ -9,7 +9,12 @@ from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
assert_identical_query_response,
get_rows,
make_query_request,
)
@@ -498,3 +503,64 @@ def test_logs_list_with_order_by(
ids = [row["data"]["attributes_string"].get("id", "") for row in rows]
assert ids == expected_order
@pytest.mark.parametrize(
"limit,offset",
[
pytest.param(2, 0, id="first_chunk"),
pytest.param(2, 1, id="first_into_second_chunk"),
pytest.param(2, 2, id="second_chunk"),
pytest.param(2, 3, id="second_into_third_chunk"),
pytest.param(2, 4, id="third_chunk"),
pytest.param(4, 5, id="last_partial_page"),
pytest.param(2, 6, id="offset_past_end"),
],
)
def test_logs_list_offset_across_chunks(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
limit: int,
offset: int,
) -> None:
"""
A 4h list runs as 1h, 2h and 1h chunks, newest first. Two logs sit in each
chunk, so the offset lands in, or crosses into, every chunk.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
minutes_ago = [10, 20, 90, 150, 200, 220]
insert_logs(
[
Logs(
timestamp=now - timedelta(minutes=minutes),
resources={"service.name": "chunked-offset-service"},
body=f"chunked-log-{minutes}",
)
for minutes in minutes_ago
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(hours=4)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="logs",
name="A",
limit=limit,
offset=offset,
filter_expression="service.name = 'chunked-offset-service'",
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc"), OrderBy(TelemetryFieldKey("id"), "desc")],
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
expected_bodies = [f"chunked-log-{minutes}" for minutes in minutes_ago][offset : offset + limit]
assert [row["data"]["body"] for row in get_rows(response)] == expected_bodies

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