Compare commits

...

14 Commits

Author SHA1 Message Date
Ashwin Bhatkal
d506b435b5 fix(notification-channels): keep the saved webhook auth while its credentials are edited 2026-10-27 22:02:50 +05:30
Ashwin Bhatkal
59cecc3f3a chore(notification-channels): cover webhook auth and the relaxed checks in the channel stories 2026-10-27 18:02:33 +05:30
Ashwin Bhatkal
9a29dc8911 feat(notification-channels): lay out the webhook auth options in a row 2026-10-27 18:02:33 +05:30
Ashwin Bhatkal
48fd849868 fix(notification-channels): flag slack rows and incident.io tokens the API would reject 2026-10-27 15:42:58 +05:30
Ashwin Bhatkal
220d78cd26 feat(notification-channels): pick webhook auth with a none, basic or bearer radio 2026-10-27 15:41:29 +05:30
Ashwin Bhatkal
09af1cb56c fix(notification-channels): drop the channel URL checks the API no longer makes 2026-10-27 15:41:12 +05:30
Srikanth Chekuri
19af7782d9 chore(openapi): describe the API payload fields and add request examples (#13200)
#### Description

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

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

#### Issues closed by this PR

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

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

#### Additional Information

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

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

---------

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

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

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

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

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


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

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


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

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

#### Additional Information

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

---------

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

#### Issues closed by this PR

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

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

#### Issues closed by this PR

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

#### Additional Information
- The "Opt-in" doc comment on the `useFieldApis` type is removed. It
said the fields API was optional, which is no longer true.
- `getValueSuggestions` was part of
https://github.com/SigNoz/engineering-pod/issues/5289.
- The prop renames from the same ticket follow in #12832, which will be
rebased on this PR.
2026-10-26 18:07:29 +00:00
Vinicius Lourenço
8a3702a7f9 chore(deps): bump handlebars,brace-expansion,storybook (#13267)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Bump deps to address the reports by Vanta.

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

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

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

#### Issues closed by this PR

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

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

#### Issues closed by this PR

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

---------

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

- The skill walks the end-to-end authz procedure from
`docs/contributing/go/authz.md` — registering kinds/resources, granting
managed-role permissions, wiring routes with `ResourceDef`s, backfilling
tuples, and writing the authz integration tests.
2026-10-26 15:50:33 +00:00
171 changed files with 10099 additions and 3364 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

385
frontend/pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,25 @@ import {
setupServer();
const activeEnvironmentFilter = buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
});
describe('CheckboxFilterV2 - interactions', () => {
describe('search functionality', () => {
it('filters values based on search text', async () => {
@@ -425,6 +444,63 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
it('opens a closed filter when the query filters on its key', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{ queryBuilderOverrides: activeEnvironmentFilter },
);
expect(screen.getByTestId('checkbox-filter-header')).toHaveAttribute(
'data-state',
'open',
);
await expect(
screen.findByTestId('checkbox-value-row-production'),
).resolves.toHaveAttribute('data-state', 'checked');
});
it('keeps the user toggle over the auto-open', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{ queryBuilderOverrides: activeEnvironmentFilter },
);
const header = screen.getByTestId('checkbox-filter-header');
await screen.findByTestId('checkbox-value-row-production');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'closed');
expect(
screen.queryByTestId('checkbox-value-row-production'),
).not.toBeInTheDocument();
await user.click(header);
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
});
describe('show more functionality', () => {

View File

@@ -34,7 +34,6 @@ import {
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
@@ -310,25 +309,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<section className="filters">
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
case FiltersType.DURATION:
return (
<Duration
@@ -340,9 +320,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
);
case FiltersType.SLIDER:
return <Slider key={filter.attributeKey.key} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return useFieldApis ? (
return (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
@@ -351,14 +330,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
onQuickFilterChange={onQuickFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
/>
);
}
})}
@@ -440,5 +411,4 @@ QuickFilters.defaultProps = {
config: [],
showFilterCollapse: true,
showQueryName: true,
useFieldApis: undefined,
};

View File

@@ -63,6 +63,7 @@ jest.mock('../../QuickFilters', () => ({
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };

View File

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

View File

@@ -7,8 +7,9 @@ import type { GlobalMockArgs } from '@/storybook/globals';
import QuickFilters from '../QuickFilters';
import {
attributeValuesHandler,
checkboxConfig,
fieldApis,
fieldValuesHandler,
handlers,
LONG_FILTER_VALUES,
loadingFiltersHandlers,
@@ -34,6 +35,7 @@ const meta = {
handleFilterVisibilityChange: (): void => undefined,
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
useFieldApis: fieldApis,
},
parameters: {
msw: { handlers },
@@ -114,7 +116,7 @@ export const SelectedExpandedCheckbox: Story = {
export const Tooltips: TooltipsStory = {
args: { signal: undefined, tooltipsOpen: true },
parameters: {
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
},
};

View File

@@ -16,7 +16,7 @@ import {
} from 'lib/authz/utils/authz-test-utils';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersFieldValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
@@ -25,7 +25,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -38,7 +38,6 @@ const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
const attributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const NOT_AUTHORIZED_TEXT = /is not authorized to perform/i;
@@ -71,11 +70,8 @@ beforeEach(() => {
rest.get(fieldsKeysURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.get(attributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
),
);
});
@@ -91,6 +87,7 @@ function renderWithSignal(): void {
source={QuickFiltersSource.LOGS_EXPLORER}
signal={SIGNAL}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
/>,
);
}
@@ -101,6 +98,7 @@ function renderStaticConfig(): void {
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
/>,
);
}

View File

@@ -6,7 +6,7 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersFieldValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
@@ -17,7 +17,7 @@ import '@testing-library/jest-dom';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -37,7 +37,6 @@ const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const FILTER_OS_DESCRIPTION = 'os.description';
@@ -62,11 +61,8 @@ const setupServer = (): void => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse)),
),
);
};
@@ -83,6 +79,7 @@ function TestQuickFilters({
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={QuickFiltersFieldApis}
signal={signal}
/>
);
@@ -105,6 +102,7 @@ function TestQuickFiltersApiMonitoring({
source={QuickFiltersSource.API_MONITORING}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={QuickFiltersFieldApis}
signal={signal}
/>
);

View File

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

View File

@@ -61,7 +61,7 @@ export interface IQuickFiltersProps {
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
useFieldApis: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
@@ -75,9 +75,6 @@ export enum QuickFiltersSource {
AI_OBSERVABILITY = 'ai-observability',
}
/**
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
*/
export type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: number;
endUnixMilli: number;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,11 @@ const COLUMNS = [
function SlackActions(): JSX.Element {
const { t } = useTranslation('channels');
const { control, register } = useFormContext<ChannelSpecFormValues>();
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
@@ -51,6 +55,9 @@ function SlackActions(): JSX.Element {
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
error={
column === 'url' ? errors.actions?.[index]?.url?.message : undefined
}
>
<Input
{...register(`actions.${index}.${column}` as const)}

View File

@@ -14,7 +14,11 @@ import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
function SlackFields(): JSX.Element {
const { t } = useTranslation('channels');
const { control, register } = useFormContext<ChannelSpecFormValues>();
const {
control,
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
@@ -46,6 +50,7 @@ function SlackFields(): JSX.Element {
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
error={errors.fields?.[index]?.value?.message}
>
<Input
{...register(`fields.${index}.value`)}

View File

@@ -0,0 +1,3 @@
.auth {
--radio-group-display: flex;
}

View File

@@ -1,17 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import {
ChannelFormValues,
WebhookAuth,
} from '../../CreateAlertChannels/types';
import { webhookAuthOf } from '../../CreateAlertChannels/utils';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './Webhook.module.scss';
function WebhookSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
} = useFormContext<ChannelFormValues>();
const [webhookAuth, username, password, bearerToken] = useWatch({
control,
name: ['webhookAuth', 'username', 'password', 'bearerToken'],
});
const auth = webhookAuthOf({ webhookAuth, username, password, bearerToken });
return (
<>
@@ -28,35 +41,75 @@ function WebhookSettings(): JSX.Element {
data-testid="webhook-url-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_username')}
help={t('help_webhook_username')}
error={errors.username?.message}
>
<Input {...register('username')} data-testid="webhook-username-textbox" />
</FormField>
<FormField
label="Password (optional)"
help={t('help_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
>
<Input
{...register('bearerToken')}
type="password"
data-testid="webhook-bearer-token-textbox"
<FormField label={t('field_webhook_auth')}>
<Controller
control={control}
name="webhookAuth"
render={({ field }): JSX.Element => (
<div className={styles.auth}>
<RadioGroup
color="primary"
aria-label={t('field_webhook_auth')}
value={auth}
onChange={field.onChange}
testId="webhook-auth-radio"
items={[
{
value: WebhookAuth.none,
label: t('option_webhook_auth_none'),
},
{
value: WebhookAuth.basic,
label: t('option_webhook_auth_basic'),
},
{
value: WebhookAuth.bearer,
label: t('option_webhook_auth_bearer'),
},
]}
/>
</div>
)}
/>
</FormField>
{auth === WebhookAuth.basic && (
<>
<FormField
label={t('field_webhook_username')}
error={errors.username?.message}
>
<Input
{...register('username')}
data-testid="webhook-username-textbox"
/>
</FormField>
<FormField
label={t('field_webhook_password')}
error={errors.password?.message}
>
<Input
{...register('password')}
type="password"
data-testid="webhook-password-textbox"
/>
</FormField>
</>
)}
{auth === WebhookAuth.bearer && (
<FormField
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
error={errors.bearerToken?.message}
required
>
<Input
{...register('bearerToken')}
type="password"
aria-required
data-testid="webhook-bearer-token-textbox"
/>
</FormField>
)}
</FormCard>
</>
);

View File

@@ -57,10 +57,6 @@
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
}

View File

@@ -19,6 +19,7 @@ import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { SavedViewCreatedFrom } from './events';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
@@ -54,7 +55,17 @@ function SavedViewsHeader({
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useSaveNewView(createView);
const saveNewView = useSaveNewView(createView);
const handleSave = useCallback(
(displayName: string): Promise<boolean> =>
saveNewView(
displayName,
modalMode === 'saveAsNew'
? SavedViewCreatedFrom.SaveAsNew
: SavedViewCreatedFrom.Header,
),
[saveNewView, modalMode],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {

View File

@@ -10,6 +10,7 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { SavedViewCreatedFrom } from './events';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
@@ -35,7 +36,7 @@ function SavedViewsList({
search: string;
}): JSX.Element {
const { viewKey } = useGetSavedViewParams();
const { selectView, clearView, createView, isSaving } =
const { selectView, clearView, resetView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
@@ -102,10 +103,10 @@ function SavedViewsList({
return;
}
if (pendingAction.view.id === viewKey) {
clearView();
resetView();
}
setPendingAction(null);
}, [pendingAction, deleteView, viewKey, clearView]);
}, [pendingAction, deleteView, viewKey, resetView]);
if (isLoading) {
return (
@@ -141,7 +142,9 @@ function SavedViewsList({
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={saveNewView}
onSave={(displayName): Promise<boolean> =>
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
}
/>
);
}

View File

@@ -0,0 +1,322 @@
import {
act,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const source = SavedviewtypesSourceDTO.traces;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
function renderAll(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
<SavedViewsPanel source={source} onClose={jest.fn()} />
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
// Buttons and menu rows ignore clicks until their permission check settles.
async function clickWhenUsable(testId: string): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId(testId)));
await userEvent.click(screen.getByTestId(testId));
}
async function chooseMenuItem(testId: string): Promise<void> {
const item = await screen.findByTestId(testId);
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
fireEvent.click(item);
}
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
}
async function chooseFromSaveMenu(key: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
async function waitForView(name: string): Promise<void> {
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
const callsOf = (event: SavedViewEvents): unknown[][] =>
logEventMock.mock.calls.filter(([name]) => name === event);
describe('SavedViews analytics', () => {
beforeEach(() => {
localStorage.clear();
logEventMock.mockClear();
jest.mocked(toast.error).mockClear();
});
it('logs a view created from the header', async () => {
mockSavedViewsApi([errors], { createdId: 'view-new' });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.Header,
}),
);
});
it('logs a view created from the empty state', async () => {
mockSavedViewsApi([], { createdId: 'view-new' });
renderAll();
await screen.findByTestId('saved-views-list-create');
await clickWhenUsable('saved-views-list-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.EmptyState,
}),
);
});
it('does not log a create that fails', async () => {
mockSavedViewsApi([errors], { createStatus: 500 });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
});
describe('on a changed view', () => {
async function openDirtyView(): Promise<void> {
const { history } = renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
}
it('does not log an update that fails', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
});
it('logs an update', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
source,
viewId: errors.id,
}),
);
});
it('logs a view created with save as new', async () => {
mockSavedViewsApi([errors], { createdId: 'view-copy' });
await openDirtyView();
await chooseFromSaveMenu('save-as-new');
await submitName('Errors copy');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-copy',
from: SavedViewCreatedFrom.SaveAsNew,
}),
);
});
it('logs discarded changes', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
logEventMock.mockClear();
await userEvent.click(screen.getByTestId('saved-views-discard'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
source,
viewId: errors.id,
});
});
});
it('logs a cleared view', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs a view cleared from its row', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs only the delete when the open view is deleted', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: errors.id,
}),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
);
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
});
it('logs a copied link', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'copyLink');
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
source,
viewId: errors.id,
});
});
it('logs edited view details', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(
SavedViewEvents.ViewDetailsEdited,
{ source, viewId: errors.id },
),
);
});
it('does not log edited view details that fail to save', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
});
it('does not log a delete that fails', async () => {
mockSavedViewsApi([errors], { deleteStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
});
});

View File

@@ -19,11 +19,15 @@ export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
updateStatus = 204,
deleteStatus = 204,
createdId = 'view-new',
getDelay = 0,
listFailures = 0,
}: {
createStatus?: number;
updateStatus?: number;
deleteStatus?: number;
createdId?: string;
getDelay?: number;
listFailures?: number;
@@ -85,20 +89,38 @@ export function mockSavedViewsApi(
const body = await req.json();
const id = String(req.params.id);
requests.updated.push({ id, body });
if (updateStatus >= 400) {
return res(
ctx.status(updateStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views[index] = { ...views[index], spec: body.spec };
}
return res(ctx.status(204));
return res(ctx.status(updateStatus));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
const id = String(req.params.id);
requests.deleted.push(id);
if (deleteStatus >= 400) {
return res(
ctx.status(deleteStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views.splice(index, 1);
}
return res(ctx.status(204));
return res(ctx.status(deleteStatus));
}),
);
return requests;

View File

@@ -0,0 +1,23 @@
export enum SavedViewEvents {
ViewCreated = 'Saved Views: View created',
ViewUpdated = 'Saved Views: View updated',
ViewDetailsEdited = 'Saved Views: View details edited',
ViewDeleted = 'Saved Views: View deleted',
LinkCopied = 'Saved Views: Link copied',
ViewCleared = 'Saved Views: View cleared',
ChangesDiscarded = 'Saved Views: Changes discarded',
}
export enum SavedViewEventKeys {
// Injected on every event by useSavedViewLogEvent
Source = 'source',
ViewId = 'viewId',
From = 'from',
}
export enum SavedViewCreatedFrom {
Header = 'header',
SaveAsNew = 'save_as_new',
EmptyState = 'empty_state',
}

View File

@@ -2,14 +2,15 @@ import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { SavedViewCreatedFrom } from '../events';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string) => Promise<boolean> {
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
return useCallback(
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
if (!(await createView(displayName, from))) {
return false;
}
toast.success('View created', {

View File

@@ -17,6 +17,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
@@ -30,12 +31,18 @@ import {
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import {
SavedViewCreatedFrom,
SavedViewEventKeys,
SavedViewEvents,
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
@@ -50,6 +57,8 @@ export function useSavedViewActions(
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
@@ -107,13 +116,16 @@ export function useSavedViewActions(
}
}
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView],
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
const resetView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
@@ -121,8 +133,18 @@ export function useSavedViewActions(
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const clearView = useCallback((): void => {
resetView();
logSavedViewEvent(SavedViewEvents.ViewCleared, {
[SavedViewEventKeys.ViewId]: viewKey,
});
}, [resetView, logSavedViewEvent, viewKey]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
async (
displayName: string,
from: SavedViewCreatedFrom,
): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
@@ -137,6 +159,10 @@ export function useSavedViewActions(
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
logSavedViewEvent(SavedViewEvents.ViewCreated, {
[SavedViewEventKeys.ViewId]: data.id,
[SavedViewEventKeys.From]: from,
});
return data.id;
} catch (error) {
toast.error(
@@ -158,6 +184,7 @@ export function useSavedViewActions(
stagedQuery,
currentQuery,
setLastUsedView,
logSavedViewEvent,
],
);
@@ -173,6 +200,9 @@ export function useSavedViewActions(
},
});
await invalidateViews(view.id);
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
toast.error(
@@ -185,13 +215,14 @@ export function useSavedViewActions(
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
);
return {
selectView,
revertView,
clearView,
resetView,
createView,
updateView,
isSaving: isCreating || isUpdating,

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
export type SavedViewLogEvent = (
event: SavedViewEvents,
attributes?: Record<string, unknown>,
) => void;
export function useSavedViewLogEvent(
source: SavedviewtypesSourceDTO,
): SavedViewLogEvent {
return useCallback(
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
void logEvent(event, {
[SavedViewEventKeys.Source]: source,
...attributes,
});
},
[source],
);
}

View File

@@ -22,9 +22,11 @@ import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
@@ -42,6 +44,7 @@ export function useSavedViewRowActions(
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard],
[pathname, copyToClipboard, logSavedViewEvent],
);
const copyViewId = useCallback(
@@ -83,13 +89,16 @@ export function useSavedViewRowActions(
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient],
[updateSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail
@@ -100,13 +109,16 @@ export function useSavedViewRowActions(
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient],
[deleteSavedView, source, queryClient, logSavedViewEvent],
);
return {

View File

@@ -10,6 +10,8 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
/** Clears the open view without logging it, for flows that are not a clear. */
resetView: () => void;
createView: (
displayName: string,
from: SavedViewCreatedFrom,
) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}

View File

@@ -1,45 +0,0 @@
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { AxiosError, AxiosResponse } from 'axios';
import { QueryKeyValueSuggestionsResponseProps } from 'types/api/querySuggestions/types';
export const useGetQueryKeyValueSuggestions = ({
key,
signal,
searchText,
signalSource,
metricName,
options,
}: {
key: string;
signal: 'traces' | 'logs' | 'metrics';
searchText?: string;
signalSource?: 'meter' | '';
options?: UseQueryOptions<
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
AxiosError
>;
metricName?: string;
}): UseQueryResult<
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
AxiosError
> =>
useQuery<AxiosResponse<QueryKeyValueSuggestionsResponseProps>, AxiosError>({
queryKey: [
'queryKeyValueSuggestions',
key,
signal,
searchText,
signalSource,
metricName,
],
queryFn: () =>
getValueSuggestions({
signal,
key,
searchText: searchText || '',
signalSource: signalSource as 'meter' | '',
metricName: metricName || '',
}),
...options,
});

View File

@@ -86,16 +86,12 @@ export const otherFiltersResponse = {
},
};
export const quickFiltersAttributeValuesResponse = {
export const quickFiltersFieldValuesResponse = {
status: 'success',
data: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
complete: true,
values: {
stringValues: ['mq-kafka', 'otel-demo', 'otlp-python', 'sample-flask'],
},
},
};

View File

@@ -16,6 +16,8 @@ import {
notificationChannelResponse,
type ChannelActionOutcome,
type ChannelType,
WEBHOOK_AUTHS,
type WebhookAuthChoice,
} from '../../stories/__story_mockdata__/alerts';
const STORY_CHANNEL_ID = '1';
@@ -38,6 +40,13 @@ export const channelsEditMocks = defineStoryMocks({
options: CHANNEL_TYPES,
value: 'slack',
}),
webhookAuth: choiceControl<WebhookAuthChoice>('Webhook credentials', {
group: CHANNEL,
description:
'What the saved webhook authenticates with, which picks the auth option the form opens on. Only read when the channel type is webhook.',
options: WEBHOOK_AUTHS,
value: 'basic',
}),
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
group: ACTIONS,
options: CHANNEL_ACTION_OUTCOMES,
@@ -53,7 +62,11 @@ export const channelsEditMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v2/notification_channels/:id',
response.json((req) =>
notificationChannelResponse(String(req.params.id), values.channelType),
notificationChannelResponse(
String(req.params.id),
values.channelType,
values.webhookAuth,
),
),
),

View File

@@ -117,6 +117,16 @@ export const Webhook: Story = {
args: { channelType: 'webhook' },
};
/** The webhook channel saved with a bearer token: only the token field shows. */
export const WebhookBearerToken: Story = {
args: { channelType: 'webhook', webhookAuth: 'bearer' },
};
/** The webhook channel saved without credentials: the auth option is None. */
export const WebhookWithoutAuth: Story = {
args: { channelType: 'webhook', webhookAuth: 'none' },
};
/**
* The Opsgenie channel, whose form carries the integration API key and the
* priority the alert is raised at.

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -42,6 +42,24 @@ const selectChannelType = async (
await within(trigger).findByText(label);
};
const fillName = async (
canvasElement: HTMLElement,
name: string,
): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByTestId('channel-name-textbox'),
name,
);
};
const sendTest = async (canvasElement: HTMLElement): Promise<void> => {
const test = within(canvasElement).getByTestId('test-channel-button');
await waitFor(() => expect(test).toHaveAttribute('aria-disabled', 'false'));
await userEvent.click(test);
await screen.findByText('An alert has been sent to this channel');
};
/** A new notification channel, on the Slack form the page opens with. */
export const Default: Story = {};
@@ -123,6 +141,79 @@ export const Webhook: Story = {
},
};
/**
* Basic auth picked on the webhook form: the username and password show, and
* saving with both empty asks for one of them.
*/
export const WebhookBasicAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-basic'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a username or password');
await expect(
canvas.queryByTestId('webhook-bearer-token-textbox'),
).not.toBeInTheDocument();
},
};
/**
* Bearer auth picked on the webhook form: only the token shows, and saving
* without one asks for it.
*/
export const WebhookBearerAuth: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Webhook$/);
await fillName(canvasElement, 'alerts-webhook');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await userEvent.click(canvas.getByTestId('webhook-auth-radio-item-bearer'));
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a bearer token');
await expect(
canvas.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
},
};
/**
* A Slack field with a title but no value, and a button with text but neither
* a URL nor a name. Each is flagged under its row rather than sent for the API
* to reject.
*/
export const SlackRowErrors: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await fillName(canvasElement, 'alerts-slack');
await userEvent.type(
canvas.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('slack-field-add'));
await userEvent.type(canvas.getByTestId('slack-field-title-0'), 'Region');
await userEvent.click(canvas.getByTestId('slack-action-add'));
await userEvent.type(canvas.getByTestId('slack-action-text-0'), 'Dashboard');
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Enter a value for this field');
await canvas.findByText('Enter a URL or a name for this button');
},
};
/** The PagerDuty form: routing key, severity and the incident details. */
export const PagerDuty: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -151,16 +242,30 @@ export const MicrosoftTeams: Story = {
},
};
/**
* The Google Chat form, whose webhook URL is rejected unless it is an https URL
* on `chat.googleapis.com`.
*/
/** The Google Chat form: the space's incoming webhook and the message text. */
export const GoogleChat: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
},
};
/**
* The Google Chat form pointed at a relay rather than `chat.googleapis.com`.
* The API accepts any webhook URL, so the test send goes through.
*/
export const GoogleChatRelayURL: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Google Chat$/);
await fillName(canvasElement, 'alerts-google-chat');
await userEvent.type(
within(canvasElement).getByTestId('webhook-url-textbox'),
'http://chat-relay.internal/v1/spaces/OPS/messages',
);
await sendTest(canvasElement);
},
};
/**
* The Jira form: where the issue is filed, the transitions that close and
* reopen it, and the Atlassian account the API token belongs to.
@@ -171,6 +276,29 @@ export const Jira: Story = {
},
};
/**
* The Jira form pointed at a proxy rather than an `atlassian.net` site. The API
* accepts any site URL, so the test send goes through.
*/
export const JiraProxySite: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Jira$/);
await fillName(canvasElement, 'alerts-jira');
await userEvent.type(
canvas.getByTestId('jira-site-textbox'),
'https://jira-proxy.acme.internal',
);
await userEvent.type(canvas.getByTestId('jira-email-textbox'), 'ops@acme.io');
await userEvent.type(canvas.getByTestId('jira-api-token-textbox'), 'token');
await userEvent.type(canvas.getByTestId('jira-project-textbox'), 'OPS');
await userEvent.type(canvas.getByTestId('jira-issue-type-textbox'), 'Bug');
await sendTest(canvasElement);
},
};
/** The Jira Service Management Ops form: the API key, priority and tags. */
export const JiraServiceManagementOps: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -185,6 +313,31 @@ export const IncidentIO: Story = {
},
};
/**
* The incident.io form pointed at a relay, which the API accepts, with the token
* pasted as the setup page shows it. The `Bearer ` prefix is flagged, since it
* would be sent twice.
*/
export const IncidentIOBearerToken: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^incident\.io$/);
await fillName(canvasElement, 'alerts-incidentio');
await userEvent.type(
canvas.getByTestId('incidentio-url-textbox'),
'https://incidentio-relay.internal/alerts',
);
await userEvent.type(
canvas.getByTestId('incidentio-token-textbox'),
'Bearer story-token',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await canvas.findByText('Paste the token only, without the Bearer prefix');
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -45,15 +45,18 @@ const CASES: PrefillCase[] = [
url: 'https://example.com/hook',
username: 'hook-user',
password: 'hook-pass',
bearerToken: 'hook-token',
},
fields: [
['webhook-url-textbox', 'https://example.com/hook'],
['webhook-username-textbox', 'hook-user'],
['webhook-password-textbox', 'hook-pass'],
['webhook-bearer-token-textbox', 'hook-token'],
],
},
{
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'hook-token' },
fields: [['webhook-bearer-token-textbox', 'hook-token']],
},
{
kind: 'email',
spec: { to: 'oncall@signoz.io', html: '<p>alert</p>' },

View File

@@ -118,6 +118,104 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends only the credentials of the picked webhook auth',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Webhook$/ }));
await user.type(screen.getByTestId('channel-name-textbox'), 'hook alerts');
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/hook',
);
await user.click(screen.getByTestId('webhook-auth-radio-item-basic'));
await user.type(screen.getByTestId('webhook-username-textbox'), 'hook-user');
await user.click(screen.getByTestId('webhook-auth-radio-item-bearer'));
expect(
screen.queryByTestId('webhook-username-textbox'),
).not.toBeInTheDocument();
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'hook-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
expect(
(body as { config: { spec: Record<string, unknown> } }).config.spec,
).toStrictEqual({
url: 'https://example.com/hook',
bearerToken: 'hook-token',
sendResolved: true,
});
},
TEST_TIMEOUT,
);
it(
'flags a slack field without a value instead of sending it',
async () => {
const onCreate = jest.fn();
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
(_, res, ctx) => {
onCreate();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(
await screen.findByTestId('channel-name-textbox', {}, FIND),
'slack alerts',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await user.click(screen.getByTestId('slack-field-add'));
await user.type(screen.getByTestId('slack-field-title-0'), 'env');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await screen.findByText('slack_field_value_required', {}, FIND);
expect(onCreate).not.toHaveBeenCalled();
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -165,6 +263,74 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps bearer auth while a saved token is cleared and replaced',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.get(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: '3',
name: 'webhook-channel',
displayName: 'webhook channel',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'old-token' },
},
},
}),
),
),
rest.put(
'http://localhost/api/v2/notification_channels/:id',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
},
),
);
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const token = await screen.findByTestId(
'webhook-bearer-token-textbox',
{},
FIND,
);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.clear(token);
await user.type(
screen.getByTestId('webhook-bearer-token-textbox'),
'new-token',
);
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(onDone).toHaveBeenCalled(), FIND);
expect(body).toStrictEqual({
config: {
kind: 'webhook',
spec: { url: 'https://example.com/hook', bearerToken: 'new-token' },
},
});
},
TEST_TIMEOUT,
);
it(
'opens read-only, with saving disabled rather than hidden, when update is denied',
async () => {

View File

@@ -781,6 +781,22 @@ export const channelNames = (count: number): string[] =>
(_unused, index) => CHANNEL_SEEDS[index % CHANNEL_SEEDS.length].name,
);
export const WEBHOOK_AUTHS = ['basic', 'bearer', 'none'] as const;
export type WebhookAuthChoice = (typeof WEBHOOK_AUTHS)[number];
const WEBHOOK_CREDENTIALS: Record<WebhookAuthChoice, Record<string, string>> = {
basic: { username: 'story-user' },
bearer: { bearerToken: 'story-bearer-token' },
none: {},
};
const webhookSpec = (auth: WebhookAuthChoice): Record<string, unknown> => ({
url: 'https://example.com/story-hook',
...WEBHOOK_CREDENTIALS[auth],
sendResolved: true,
});
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
@@ -809,11 +825,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
],
sendResolved: true,
},
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
webhook: webhookSpec('basic'),
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
@@ -877,6 +889,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
export const notificationChannelResponse = (
id: string,
type: ChannelType,
webhookAuth: WebhookAuthChoice = 'basic',
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
@@ -897,7 +910,7 @@ export const notificationChannelResponse = (
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: CHANNEL_SPECS[type],
spec: type === 'webhook' ? webhookSpec(webhookAuth) : CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};

View File

@@ -25,9 +25,6 @@
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}

View File

@@ -5,7 +5,6 @@ import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
@@ -20,7 +19,6 @@ interface MessagesAccordionProps extends Pick<
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -28,7 +26,6 @@ function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
@@ -36,10 +33,8 @@ function MessagesAccordion({
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
if (messages.length === 0) {
return null;
}
@@ -66,12 +61,12 @@ function MessagesAccordion({
{title}
</span>
),
extra: isOpen && canFormat && (
extra: isOpen && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
value={view}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
@@ -79,9 +74,8 @@ function MessagesAccordion({
),
children: (
<MessagesSection
view={effectiveView}
view={view}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
@@ -95,8 +89,4 @@ function MessagesAccordion({
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -3,7 +3,10 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import {
getSpanMessages,
hasFormattedMessages,
} from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
@@ -25,7 +28,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
toolCallKeys,
);
if (!hasAnyMessages(data)) {
if (!hasFormattedMessages(data)) {
return null;
}
@@ -35,7 +38,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -45,7 +47,6 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -47,6 +47,7 @@
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
// Truncating text inside a badge (service name, status message).

View File

@@ -3,8 +3,7 @@ import AIThreadMessage, {
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import styles from './SpanMessages.module.scss';
@@ -17,7 +16,6 @@ interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -25,21 +23,19 @@ function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
if (messages.length === 0) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
{view === ThreadView.Formatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
@@ -54,7 +50,7 @@ function MessagesSection({
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
<JsonBlock data={messages} />
)}
</section>
);
@@ -62,7 +58,6 @@ function MessagesSection({
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -1,21 +0,0 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -28,12 +28,6 @@
min-width: 0;
}
.rawUnavailable {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.missing {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);

View File

@@ -6,11 +6,7 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
@@ -32,9 +28,8 @@ function SpanMessages({
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
if (!hasFormattedMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
@@ -45,11 +40,11 @@ function SpanMessages({
);
}
if (!isFormatted) {
if (view === ThreadView.Json) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
data={{ input: data.formattedInput, output: data.formattedOutput }}
/>
</div>
);
@@ -60,7 +55,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -69,7 +63,6 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -48,41 +48,36 @@ describe('SpanMessages', () => {
).toBeInTheDocument();
});
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
it('shows the empty state in JSON view when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders raw messages in JSON view', () => {
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
],
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
});
it('opens every key in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'hi' },
]),
},
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
});
render(<SpanMessages span={span} view={ThreadView.Json} />);

View File

@@ -99,10 +99,12 @@ function TraceDetailsHeader({
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
history.goBack();
} else if (!isOverview) {
history.push(ROUTES.AI_OBSERVABILITY_EXPLORER);
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, []);
}, [isOverview]);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);

View File

@@ -22,7 +22,8 @@ function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
void setTab(value as TraceDetailsTab, { history: 'push' });
};
return (

View File

@@ -1,10 +1,8 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
@@ -100,6 +98,22 @@ describe('TraceDetailsHeader – back button', () => {
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
it('pushes to the AI observability explorer when opened directly on another tab', async () => {
mockHasInAppHistory.mockReturnValue(false);
const user = userEvent.setup({ delay: null });
render(
<NuqsTestingAdapter searchParams="?tab=thread">
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(ROUTES.AI_OBSERVABILITY_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader – action cluster', () => {

View File

@@ -1,29 +1,31 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
max-width: 428px;
padding: var(--spacing-16);
text-align: center;
}
.emoji {
font-size: 32px;
line-height: 1;
.noDataImg {
height: 32px;
width: 32px;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.highlight {
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
margin-top: var(--spacing-4);
}

View File

@@ -1,8 +1,9 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import noDataUrl from '@/assets/Icons/no-data.svg';
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
import styles from './ThreadNoAiSpans.module.scss';
@@ -10,32 +11,28 @@ import styles from './ThreadNoAiSpans.module.scss';
function ThreadNoAiSpans(): JSX.Element {
return (
<div className={styles.root} data-testid="thread-empty">
<div className={styles.message}>
<span className={styles.emoji} aria-hidden>
🚏
</span>
<div className={styles.paragraphs}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
<img src={noDataUrl} alt="" className={styles.noDataImg} />
<span className={styles.title}>
This trace has no spans from AI-related actions
</span>
<span className={styles.description}>
Thread view summarising your entire AI-journey history will appear here when
the spans have the necessary datapoints.
</span>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
);
}

View File

@@ -40,6 +40,7 @@ function TraceDetailsThread(): JSX.Element {
spansAboveFirstPage,
isLoading,
isError,
isNotFound,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
@@ -52,6 +53,7 @@ function TraceDetailsThread(): JSX.Element {
interestedSpanId,
spans,
isLoading,
isNotFound,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getTraceThread } from 'api/generated/services/tracedetail';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { useTraceThread } from '../useTraceThread';
@@ -154,6 +154,7 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(1);
expect(result.current.isNotFound).toBe(true);
});
it('retries other errors', async () => {
@@ -162,5 +163,6 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
expect(result.current.isNotFound).toBe(false);
});
});

View File

@@ -25,6 +25,8 @@ interface UseTraceThreadResult {
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
/** The interested span (or trace) doesn't exist; an empty thread is not this. */
isNotFound: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
@@ -99,6 +101,7 @@ export function useTraceThread({
spansAboveFirstPage,
isLoading: query.isLoading,
isError: query.isError,
isNotFound: isNotFoundError(query.error),
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,

View File

@@ -28,6 +28,7 @@ export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
isNotFound: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
@@ -35,9 +36,13 @@ export function getInterestedSpanStatus(
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (spans.length === 0) {
if (isNotFound) {
return InterestedSpanStatus.NotFound;
}
// The span exists but the thread is empty, so the no-AI-spans state applies.
if (spans.length === 0) {
return InterestedSpanStatus.None;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;

View File

@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
}
export const LLM_OBSERVABILITY_DOCS_URL =
'https://signoz.io/docs/llm-observability/';
'https://signoz.io/docs/ai-observability/';

View File

@@ -1,49 +1,19 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from './aiTraceUtils';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// Spans from before the JSON column have no formatted messages.
return {
formattedInput: span.formattedInput ?? [],
formattedOutput: span.formattedOutput ?? [],
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasRawMessages(data: SpanMessagesData): boolean {
return !!data.rawInput || !!data.rawOutput;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || hasRawMessages(data);
}

View File

@@ -37,22 +37,4 @@ export interface QueryKeyRequestProps {
signalSource?: 'meter' | '';
}
export interface QueryKeyValueSuggestionsProps {
id: string;
name: string;
}
export interface QueryKeyValueSuggestionsResponseProps {
status: string;
data: QueryKeyValueSuggestionsProps[];
}
export interface QueryKeyValueRequestProps {
signal: 'traces' | 'logs' | 'metrics';
key: string;
searchText: string;
signalSource?: 'meter' | '';
metricName?: string;
}
export type SignalType = 'traces' | 'logs' | 'metrics';

View File

@@ -140,6 +140,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint creates a notification channel",
Request: new(alertmanagertypes.PostableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: createNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -224,6 +225,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.",
Request: new(alertmanagertypes.UpdatableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: updateNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -309,6 +311,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.",
Request: new(alertmanagertypes.TestableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: testNotificationChannelExamples(),
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -0,0 +1,201 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// channelConfig wraps one provider spec in the {kind, spec} envelope every
// channel write carries.
func channelConfig(kind string, spec map[string]any) map[string]any {
return map[string]any{"kind": kind, "spec": spec}
}
func slackChannelSpec() map[string]any {
return map[string]any{
"apiUrl": "https://hooks.slack.com/services/T000/B000/XXXXXXXX",
"channel": "#alerts-platform",
"sendResolved": true,
}
}
func pagerdutyChannelSpec() map[string]any {
return map[string]any{
"routingKey": "R0000000000000000000000000000000",
"severity": "critical",
}
}
func webhookChannelSpec() map[string]any {
return map[string]any{
"url": "https://alerts.example.com/signoz",
"bearerToken": "00000000-0000-0000-0000-000000000000",
}
}
// createNotificationChannelExamples returns one create payload per channel
// kind. Each carries the kind's required fields plus one or two optional ones
// that show a default being overridden.
func createNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Slack incoming webhook",
Description: "Posts to a Slack channel through an incoming webhook. `sendResolved` is off by default for Slack, so it is turned on here.",
Value: map[string]any{
"name": "slack-platform",
"displayName": "Slack platform",
"config": channelConfig("slack", slackChannelSpec()),
},
},
{
Name: "email",
Summary: "Email recipients",
Description: "Sends to a comma-separated recipient list. SMTP transport comes from the deployment's global config, so only recipients and body are configurable.",
Value: map[string]any{
"name": "email-oncall",
"displayName": "Email on-call",
"config": channelConfig("email", map[string]any{
"to": "oncall@example.com, platform@example.com",
"headers": map[string]any{"Subject": "[SigNoz] {{ .CommonLabels.alertname }}"},
}),
},
},
{
Name: "webhook_bearer",
Summary: "Webhook with a bearer token",
Description: "Delivers the Alertmanager webhook payload to an HTTP endpoint. Use either `bearerToken` or `username`/`password`, never both.",
Value: map[string]any{
"name": "webhook-incident-bot",
"displayName": "Incident bot webhook",
"config": channelConfig("webhook", webhookChannelSpec()),
},
},
{
Name: "pagerduty",
Summary: "PagerDuty Events API v2",
Description: "Creates PagerDuty incidents through an Events API v2 integration key.",
Value: map[string]any{
"name": "pagerduty-oncall",
"displayName": "PagerDuty on-call",
"config": channelConfig("pagerduty", pagerdutyChannelSpec()),
},
},
{
Name: "opsgenie",
Summary: "Opsgenie API integration",
Description: "Creates Opsgenie alerts through an API integration key, pinned to priority P2.",
Value: map[string]any{
"name": "opsgenie-sre",
"displayName": "Opsgenie SRE",
"config": channelConfig("opsgenie", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P2",
}),
},
},
{
Name: "msteams",
Summary: "Microsoft Teams incoming webhook",
Description: "Posts an adaptive card to a Teams channel through an incoming webhook.",
Value: map[string]any{
"name": "msteams-platform",
"displayName": "Teams platform",
"config": channelConfig("msteams", map[string]any{
"webhookUrl": "https://example.webhook.office.com/webhookb2/00000000-0000-0000-0000-000000000000",
}),
},
},
{
Name: "googlechat",
Summary: "Google Chat space webhook",
Description: "Posts to a Google Chat space through its webhook URL.",
Value: map[string]any{
"name": "googlechat-platform",
"displayName": "Google Chat platform",
"config": channelConfig("googlechat", map[string]any{
"webhookUrl": "https://chat.googleapis.com/v1/spaces/AAAA0000000/messages?key=XXXX&token=XXXX",
}),
},
},
{
Name: "jira",
Summary: "Jira Cloud issues",
Description: "Creates one Jira Cloud issue per alert group and reopens it if the alert fires again within `reopenDuration`. Authenticates with an Atlassian account email and API token.",
Value: map[string]any{
"name": "jira-platform",
"displayName": "Jira platform",
"config": channelConfig("jira", map[string]any{
"site": "https://example.atlassian.net",
"project": "OPS",
"issueType": "Bug",
"email": "oncall@example.com",
"apiToken": "XXXXXXXXXXXXXXXXXXXXXXXX",
"labels": []any{"signoz", "alert"},
"reopenDuration": "3d",
}),
},
},
{
Name: "jsmops",
Summary: "Jira Service Management Ops",
Description: "Creates JSM Ops alerts through an integration API key. JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to P5.",
Value: map[string]any{
"name": "jsmops-oncall",
"displayName": "JSM Ops on-call",
"config": channelConfig("jsmops", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P1",
"tags": "signoz,platform",
}),
},
},
{
Name: "incidentio",
Summary: "incident.io HTTP alert source",
Description: "Sends alert events to an incident.io HTTP alert source. `url` and `token` are copied from the source's setup page; the token goes without the Bearer prefix.",
Value: map[string]any{
"name": "incidentio-platform",
"displayName": "incident.io platform",
"config": channelConfig("incidentio", map[string]any{
"url": "https://api.incident.io/v2/alert_events/http/00000000-0000-0000-0000-000000000000",
"token": "XXXXXXXXXXXXXXXXXXXXXXXX",
"metadata": map[string]any{"team": "platform"},
}),
},
},
}
}
// updateNotificationChannelExamples shows that an update carries only config:
// name and displayName are immutable, and the kind may change.
func updateNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack_rotate_webhook",
Summary: "Replace a Slack webhook URL",
Description: "Replaces the stored configuration in full; every field not sent falls back to its default.",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "switch_to_pagerduty",
Summary: "Change the kind to PagerDuty",
Description: "The kind may change on update, replacing the channel's notifier configuration while its name and references stay.",
Value: map[string]any{"config": channelConfig("pagerduty", pagerdutyChannelSpec())},
},
}
}
// testNotificationChannelExamples sends one test notification; the body is a
// configuration only because nothing is persisted.
func testNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Test a Slack webhook",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "webhook",
Summary: "Test a webhook endpoint",
Value: map[string]any{"config": channelConfig("webhook", webhookChannelSpec())},
},
}
}

View File

@@ -0,0 +1,39 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/stretchr/testify/require"
)
func TestNotificationChannelExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createNotificationChannelExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable alertmanagertypes.PostableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &postable))
require.NoError(t, postable.Validate())
})
}
for _, ex := range updateNotificationChannelExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var updatable alertmanagertypes.UpdatableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &updatable))
require.NoError(t, updatable.Validate())
})
}
for _, ex := range testNotificationChannelExamples() {
t.Run("test/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var testable alertmanagertypes.TestableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &testable))
require.NoError(t, testable.Validate())
})
}
}

View File

@@ -77,6 +77,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
RequestExamples: createDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -200,6 +201,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
RequestExamples: updateDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -233,6 +235,7 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
RequestExamples: patchDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -0,0 +1,268 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// gridItem places a panel on the 12-column grid of a Grid layout.
func gridItem(panelID string, x, y, width, height int) map[string]any {
return map[string]any{
"x": x, "y": y, "width": width, "height": height,
"content": map[string]any{"$ref": "#/spec/panels/" + panelID},
}
}
func gridLayout(items ...map[string]any) map[string]any {
list := make([]any, 0, len(items))
for _, item := range items {
list = append(list, item)
}
return map[string]any{"kind": "Grid", "spec": map[string]any{"items": list}}
}
// builderQuery wraps a builder query spec in the panel query envelope.
// requestType is the result shape the panel renders: time_series, scalar or raw.
func builderQuery(requestType string, spec map[string]any) map[string]any {
return map[string]any{
"kind": requestType,
"spec": map[string]any{
"name": spec["name"],
"plugin": map[string]any{"kind": "signoz/BuilderQuery", "spec": spec},
},
}
}
func panel(name, description string, plugin map[string]any, queries ...map[string]any) map[string]any {
list := make([]any, 0, len(queries))
for _, q := range queries {
list = append(list, q)
}
return map[string]any{
"kind": "Panel",
"spec": map[string]any{
"display": map[string]any{"name": name, "description": description},
"links": []any{},
"plugin": plugin,
"queries": list,
},
}
}
func serviceNameGroupBy() []any {
return []any{map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}}
}
// createDashboardExamples returns example payloads for the dashboard create
// endpoint. They cover a variable-driven service overview and a logs dashboard
// that pairs a query-less text panel with a table.
func createDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "service_overview",
Summary: "Service overview with a variable, a time series panel and a number panel",
Description: "A `$service` list variable fed by the observed `service.name` values filters both panels. The time series panel counts spans per service; the number panel renders a single scalar and turns red above 100 errors. `generateName` lets the server derive the name from the display name.",
Value: map[string]any{
"schemaVersion": "v6",
"generateName": true,
"tags": []any{map[string]any{"key": "team", "value": "platform"}},
"spec": map[string]any{
"display": map[string]any{"name": "Service Overview", "description": "Request rate and errors per service"},
"variables": []any{
map[string]any{
"kind": "ListVariable",
"spec": map[string]any{
"name": "service",
"display": map[string]any{"name": "Service"},
"allowMultiple": true,
"allowAllValue": true,
"sort": "alphabetical-asc",
"plugin": map[string]any{
"kind": "signoz/DynamicVariable",
"spec": map[string]any{"name": "service.name", "signal": "traces"},
},
},
},
},
"panels": map[string]any{
"request-rate": panel("Requests", "Spans per service",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"legend": map[string]any{"position": "bottom"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "requests"}},
"filter": map[string]any{"expression": "service.name IN $service"},
"groupBy": serviceNameGroupBy(),
"legend": "{{service.name}}",
}),
),
"error-count": panel("Errors", "Spans with an error status",
map[string]any{
"kind": "signoz/NumberPanel",
"spec": map[string]any{
"thresholds": []any{
map[string]any{"value": 100, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "has_error = true AND service.name IN $service"},
}),
),
},
"layouts": []any{gridLayout(
gridItem("request-rate", 0, 0, 8, 6),
gridItem("error-count", 8, 0, 4, 6),
)},
"duration": "1h",
"links": []any{},
},
},
},
{
Name: "logs_table_with_runbook",
Summary: "Logs dashboard with a text panel and a table panel",
Description: "A `signoz/TextPanel` renders markdown and must send `queries: []`. The table counts error logs per service as a scalar result, orders by the `errors` alias and colors cells above 1000 errors; the threshold names the column by its key `A` (the query name), not by the alias.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "error-logs-by-service",
"tags": []any{},
"spec": map[string]any{
"display": map[string]any{"name": "Error Logs by Service", "description": ""},
"variables": []any{},
"panels": map[string]any{
"runbook": panel("Runbook", "What to do when errors spike",
map[string]any{
"kind": "signoz/TextPanel",
"spec": map[string]any{
"mode": "markdown",
"text": "## Error logs\n\n1. Find the service with the most errors in the table.\n2. Open its logs and group by `code.function`.",
"presentation": map[string]any{"textAlign": "left", "verticalAlign": "top"},
"headerOptions": map[string]any{"hide": false},
},
},
),
"errors-by-service": panel("Error logs per service", "",
map[string]any{
"kind": "signoz/TablePanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"thresholds": []any{
map[string]any{"columnName": "A", "value": 1000, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"groupBy": serviceNameGroupBy(),
"order": []any{map[string]any{"key": map[string]any{"name": "errors"}, "direction": "desc"}},
"limit": 20,
}),
),
},
"layouts": []any{gridLayout(
gridItem("runbook", 0, 0, 4, 6),
gridItem("errors-by-service", 4, 0, 8, 6),
)},
"links": []any{},
},
},
},
}
}
// updateDashboardExamples returns the example payload for the dashboard update
// endpoint: the full replacement shape, including the immutable name.
func updateDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "replace_dashboard",
Summary: "Replace a dashboard with a single CPU time series panel",
Description: "Update is a full replacement: every field is written as sent, so fetch the dashboard first and resend the unchanged parts. `name` must equal the dashboard's current name.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "host-cpu",
"tags": []any{map[string]any{"key": "team", "value": "infra"}},
"spec": map[string]any{
"display": map[string]any{"name": "Host CPU", "description": "CPU time by state"},
"variables": []any{},
"panels": map[string]any{
"cpu-time": panel("CPU time", "Rate of system.cpu.time by state",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"chartAppearance": map[string]any{"fillMode": "gradient"},
"legend": map[string]any{"position": "right"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{map[string]any{
"metricName": "system.cpu.time",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
"groupBy": []any{map[string]any{"name": "state", "fieldContext": "attribute", "fieldDataType": "string"}},
"legend": "{{state}}",
}),
),
},
"layouts": []any{gridLayout(gridItem("cpu-time", 0, 0, 12, 7))},
"duration": "3h",
"refreshInterval": "30s",
"links": []any{},
},
},
},
}
}
// patchDashboardExamples returns example payloads for the dashboard patch
// endpoint. The request body is the RFC 6902 operation array itself.
func patchDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "rename",
Summary: "Rename a dashboard",
Description: "Replaces the display name without resending anything else.",
Value: []any{
map[string]any{"op": "replace", "path": "/spec/display/name", "value": "Service Overview (production)"},
},
},
{
Name: "add_panel",
Summary: "Add a panel and place it",
Description: "Adding a panel takes two operations: one adds the panel under `/spec/panels/<id>`, the other appends a grid item that references it to the first layout. A panel that no layout places is accepted but not shown.",
Value: []any{
map[string]any{
"op": "add",
"path": "/spec/panels/error-rate",
"value": panel("Error count", "Errored spans per time bucket",
map[string]any{"kind": "signoz/TimeSeriesPanel", "spec": map[string]any{}},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "has_error = true"},
}),
),
},
map[string]any{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": gridItem("error-rate", 0, 7, 12, 6),
},
},
},
}
}

View File

@@ -0,0 +1,49 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestDashboardExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createDashboardExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, json.Unmarshal(raw, &postable))
})
}
var base dashboardtypes.UpdatableDashboardV2
for _, ex := range updateDashboardExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &base))
})
}
existing := &dashboardtypes.DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Source: dashboardtypes.SourceUser,
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Spec: base.Spec,
}
for _, ex := range patchDashboardExamples() {
t.Run("patch/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var patch dashboardtypes.PatchableDashboardV2
require.NoError(t, json.Unmarshal(raw, &patch))
_, err = patch.Apply(existing)
require.NoError(t, err)
})
}
}

View File

@@ -70,6 +70,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Description: "This endpoint returns a treemap visualization showing the proportional distribution of metrics by sample count or time series count",
Request: new(metricsexplorertypes.TreemapRequest),
RequestContentType: "application/json",
RequestExamples: treemapRequestExamples(),
Response: new(metricsexplorertypes.TreemapResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -0,0 +1,33 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// treemapRequestExamples returns example payloads for the metrics treemap
// endpoint, one per mode.
func treemapRequestExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "treemap_timeseries",
Summary: "Top metrics by time series count",
Description: "Sizes each metric by the number of distinct time series it produced in the time range, limited to the 50 largest. Only the `timeseries` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 50,
"mode": "timeseries",
},
},
{
Name: "treemap_samples_filtered",
Summary: "Top metrics by sample count for one host",
Description: "Sizes each metric by the number of samples ingested, restricted with a query builder filter on a metric attribute. Only the `samples` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 20,
"mode": "samples",
"filter": map[string]any{"expression": "host.name = 'web-1'"},
},
},
}
}

View File

@@ -0,0 +1,21 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/stretchr/testify/require"
)
func TestTreemapExamplesAreValidPayloads(t *testing.T) {
for _, ex := range treemapRequestExamples() {
t.Run(ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var req metricsexplorertypes.TreemapRequest
require.NoError(t, json.Unmarshal(raw, &req))
require.NoError(t, req.Validate())
})
}
}

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