Compare commits

..

6 Commits

Author SHA1 Message Date
Abhi Kumar
5d28d0b6a0 test(dashboards): expose the uPlot instance on the chart container
A chart is a canvas, so a test cannot read its series, scales or bands any other way. Both the
e2e suite and the panel stories read it.
2026-10-27 00:11:17 +05:30
Aditya Singh
1e89b49ecf chore(quick-filters): remove the old checkbox filter (#13262)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description
- 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
76 changed files with 2269 additions and 4139 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`.

View File

@@ -8616,6 +8616,8 @@ components:
type: string
query:
type: string
skipStepAlignment:
type: boolean
stats:
type: boolean
step:
@@ -13196,9 +13198,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:list
- VIEWER
- tokenizer:
- planned-maintenance:list
- VIEWER
summary: List downtime schedules
tags:
- downtimeschedules
@@ -13253,13 +13255,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- EDITOR
- tokenizer:
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- EDITOR
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -13304,9 +13302,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:delete
- EDITOR
- tokenizer:
- planned-maintenance:delete
- EDITOR
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -13362,9 +13360,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:read
- VIEWER
- tokenizer:
- planned-maintenance:read
- VIEWER
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -13419,17 +13417,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- EDITOR
- tokenizer:
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- EDITOR
summary: Update downtime schedule
tags:
- downtimeschedules
@@ -14712,9 +14702,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- route-policy:list
- VIEWER
- tokenizer:
- route-policy:list
- VIEWER
summary: List route policies
tags:
- routepolicies
@@ -14769,13 +14759,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- route-policy:create
- route-policy:attach
- notification-channel:attach
- ADMIN
- tokenizer:
- route-policy:create
- route-policy:attach
- notification-channel:attach
- ADMIN
summary: Create route policy
tags:
- routepolicies
@@ -14820,9 +14806,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- route-policy:delete
- ADMIN
- tokenizer:
- route-policy:delete
- ADMIN
summary: Delete route policy
tags:
- routepolicies
@@ -14878,9 +14864,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- route-policy:read
- VIEWER
- tokenizer:
- route-policy:read
- VIEWER
summary: Get route policy by ID
tags:
- routepolicies
@@ -14947,17 +14933,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- route-policy:update
- route-policy:attach
- route-policy:detach
- notification-channel:attach
- notification-channel:detach
- ADMIN
- tokenizer:
- route-policy:update
- route-policy:attach
- route-policy:detach
- notification-channel:attach
- notification-channel:detach
- ADMIN
summary: Update route policy
tags:
- routepolicies
@@ -22355,9 +22333,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: List rule saved views
tags:
- rules
@@ -22413,9 +22391,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Create rule saved view
tags:
- rules
@@ -22467,9 +22445,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Delete rule saved view
tags:
- rules
@@ -22536,9 +22514,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:list
- VIEWER
- tokenizer:
- rule:list
- VIEWER
summary: Update rule saved view
tags:
- rules

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

@@ -6116,6 +6116,10 @@ export interface Querybuildertypesv5PromQueryDTO {
* @type string
*/
query?: string;
/**
* @type boolean
*/
skipStepAlignment?: boolean;
/**
* @type boolean
*/

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

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

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

@@ -21,15 +21,7 @@ export default {
{
kind: 'notification-channel',
type: 'metaresource',
allowedVerbs: [
'attach',
'create',
'delete',
'detach',
'list',
'read',
'update',
],
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'quick-filter',

View File

@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
import { prepareAlignedData, setInstanceHandle } from './utils';
/**
* Check if dimensions have changed
@@ -58,6 +58,7 @@ export default function UPlotChart({
onDestroy?.(plotInstanceRef.current);
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setInstanceHandle(containerRef.current, null);
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
@@ -101,6 +102,7 @@ export default function UPlotChart({
});
plotInstanceRef.current = plot;
setInstanceHandle(containerRef.current, plot);
}, [
config,
data,

View File

@@ -1,6 +1,6 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
import uPlot, { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
@@ -14,3 +14,22 @@ export function prepareAlignedData({
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}
/** A chart container carrying its live uPlot instance, for tests and debugging. */
export interface UPlotHostElement extends HTMLDivElement {
__uplot?: uPlot;
}
export function setInstanceHandle(
node: HTMLDivElement | null,
plot: uPlot | null,
): void {
if (!node) {
return;
}
if (plot) {
(node as UPlotHostElement).__uplot = plot;
} else {
delete (node as UPlotHostElement).__uplot;
}
}

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

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

@@ -34,9 +34,6 @@ type Alertmanager interface {
// ListChannels lists all channels for the organization.
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
// GetChannelIDsByNames resolves display names to channel ids and rejects unknown names.
GetChannelIDsByNames(context.Context, string, []string) ([]string, error)
// ListAllChannels lists all channels for all organizations. It is used by the legacy alertmanager only.
ListAllChannels(context.Context) ([]*alertmanagertypes.Channel, error)

View File

@@ -1023,80 +1023,6 @@ func (_c *MockAlertmanager_GetChannelByID_Call) RunAndReturn(run func(context1 c
return _c
}
// GetChannelIDsByNames provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetChannelIDsByNames(context1 context.Context, s string, strings []string) ([]string, error) {
ret := _mock.Called(context1, s, strings)
if len(ret) == 0 {
panic("no return value specified for GetChannelIDsByNames")
}
var r0 []string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) ([]string, error)); ok {
return returnFunc(context1, s, strings)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) []string); ok {
r0 = returnFunc(context1, s, strings)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok {
r1 = returnFunc(context1, s, strings)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAlertmanager_GetChannelIDsByNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChannelIDsByNames'
type MockAlertmanager_GetChannelIDsByNames_Call struct {
*mock.Call
}
// GetChannelIDsByNames is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - strings []string
func (_e *MockAlertmanager_Expecter) GetChannelIDsByNames(context1 any, s any, strings any) *MockAlertmanager_GetChannelIDsByNames_Call {
return &MockAlertmanager_GetChannelIDsByNames_Call{Call: _e.mock.On("GetChannelIDsByNames", context1, s, strings)}
}
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Run(run func(context1 context.Context, s string, strings []string)) *MockAlertmanager_GetChannelIDsByNames_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 []string
if args[2] != nil {
arg2 = args[2].([]string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Return(strings1 []string, err error) *MockAlertmanager_GetChannelIDsByNames_Call {
_c.Call.Return(strings1, err)
return _c
}
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) RunAndReturn(run func(context1 context.Context, s string, strings []string) ([]string, error)) *MockAlertmanager_GetChannelIDsByNames_Call {
_c.Call.Return(run)
return _c
}
// GetConfig provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetConfig(context1 context.Context, s string) (*alertmanagertypes.Config, error) {
ret := _mock.Called(context1, s)

View File

@@ -147,19 +147,6 @@ func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*al
return channels, err
}
func (provider *provider) GetChannelIDsByNames(ctx context.Context, orgID string, names []string) ([]string, error) {
if len(names) == 0 {
return nil, nil
}
channels, _, err := provider.configStore.ListChannels(ctx, orgID, nil)
if err != nil {
return nil, err
}
return alertmanagertypes.MapChannelNamesToIDs(channels, names)
}
func (provider *provider) ListNotificationChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
channels, total, err := provider.configStore.ListChannels(ctx, orgID, params)
if err != nil {
@@ -541,10 +528,6 @@ func (provider *provider) CreateRoutePolicy(ctx context.Context, routeRequest *a
return nil, err
}
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), routeRequest.Channels); err != nil {
return nil, err
}
route := alertmanagertypes.RoutePolicy{
Expression: routeRequest.Expression,
ExpressionKind: routeRequest.ExpressionKind,
@@ -737,10 +720,6 @@ func (provider *provider) UpdateRoutePolicyByID(ctx context.Context, routeID str
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid route: %v", err)
}
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), route.Channels); err != nil {
return nil, err
}
existingRoute, err := provider.notificationManager.GetRoutePolicyByID(ctx, claims.OrgID, routeID)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeNotFound, "route not found: %v", err)

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -328,167 +327,88 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/route_policies", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetAllRoutePolicies, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"routepolicies"},
Summary: "List route policies",
Description: "This endpoint lists all route policies for the organization",
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRoutePolicy,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"routepolicies"},
Summary: "List route policies",
Description: "This endpoint lists all route policies for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetRoutePolicyByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetRoutePolicyByID",
Tags: []string{"routepolicies"},
Summary: "Get route policy by ID",
Description: "This endpoint returns a route policy by ID",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRoutePolicy,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetRoutePolicyByID), handler.OpenAPIDef{
ID: "GetRoutePolicyByID",
Tags: []string{"routepolicies"},
Summary: "Get route policy by ID",
Description: "This endpoint returns a route policy by ID",
Request: nil,
RequestContentType: "",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.CreateRoutePolicy, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateRoutePolicy",
Tags: []string{"routepolicies"},
Summary: "Create route policy",
Description: "This endpoint creates a route policy",
Request: new(alertmanagertypes.PostableRoutePolicy),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRoutePolicy,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewCreateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.CreateRoutePolicy), handler.OpenAPIDef{
ID: "CreateRoutePolicy",
Tags: []string{"routepolicies"},
Summary: "Create route policy",
Description: "This endpoint creates a route policy",
Request: new(alertmanagertypes.PostableRoutePolicy),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.UpdateRoutePolicy, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateRoutePolicy",
Tags: []string{"routepolicies"},
Summary: "Update route policy",
Description: "This endpoint updates a route policy by ID",
Request: new(alertmanagertypes.PostableRoutePolicy),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRoutePolicy,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewUpdateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
TargetIDs: ruler.NewUpdateRoutePolicyChannelDetachExtractor(provider.alertmanagerService),
TargetSelector: coretypes.IDSelector,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.UpdateRoutePolicy), handler.OpenAPIDef{
ID: "UpdateRoutePolicy",
Tags: []string{"routepolicies"},
Summary: "Update route policy",
Description: "This endpoint updates a route policy by ID",
Request: new(alertmanagertypes.PostableRoutePolicy),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.DeleteRoutePolicyByID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRoutePolicyByID",
Tags: []string{"routepolicies"},
Summary: "Delete route policy",
Description: "This endpoint deletes a route policy by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRoutePolicy,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.DeleteRoutePolicyByID), handler.OpenAPIDef{
ID: "DeleteRoutePolicyByID",
Tags: []string{"routepolicies"},
Summary: "Delete route policy",
Description: "This endpoint deletes a route policy by ID",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -91,11 +91,9 @@ type provider struct {
ruleStateHistoryHandler rulestatehistory.Handler
spanMapperHandler spanmapper.Handler
alertmanagerHandler alertmanager.Handler
alertmanagerService alertmanager.Alertmanager
prometheusHandler prometheus.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
rulerService ruler.Ruler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
@@ -138,12 +136,10 @@ func NewFactory(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
alertmanagerService alertmanager.Alertmanager,
prometheusHandler prometheus.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -193,12 +189,10 @@ func NewFactory(
ruleStateHistoryHandler,
spanMapperHandler,
alertmanagerHandler,
alertmanagerService,
prometheusHandler,
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
rulerService,
statsHandler,
savedViewHandler,
globalConfig,
@@ -250,12 +244,10 @@ func newProvider(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
alertmanagerService alertmanager.Alertmanager,
prometheusHandler prometheus.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -307,11 +299,9 @@ func newProvider(
ruleStateHistoryHandler: ruleStateHistoryHandler,
spanMapperHandler: spanMapperHandler,
alertmanagerHandler: alertmanagerHandler,
alertmanagerService: alertmanagerService,
prometheusHandler: prometheusHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
rulerService: rulerService,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,

View File

@@ -4,11 +4,8 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/gorilla/mux"
)
@@ -149,260 +146,131 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRuleViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
ID: "GetDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.DowntimeRuleIDsExtractor,
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryConfigurationChange,
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceMetaResourceRule,
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
TargetSelector: ruler.DowntimeRuleSelector,
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
ID: "UpdateDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
ID: "DeleteDowntimeScheduleByID",
Tags: []string{"downtimeschedules"},
Summary: "Delete downtime schedule",
Description: "This endpoint deletes a downtime schedule by ID",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -176,7 +176,11 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
// update the status but do not delete the role mappings as we will use them for audits
err = module.store.DeleteServiceAccountRolesByServiceAccountID(ctx, id)
if err != nil {
return err
}
err = module.store.Update(ctx, orgID, serviceAccount.ServiceAccount)
if err != nil {
return err

View File

@@ -243,6 +243,21 @@ func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccount
return nil
}
func (store *store) DeleteServiceAccountRolesByServiceAccountID(ctx context.Context, serviceAccountID valuer.UUID) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(serviceaccounttypes.ServiceAccountRole)).
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceaccounttypes.FactorAPIKey) error {
_, err := store.
sqlstore.

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -89,14 +90,15 @@ func (bc *bucketCache) GetMissRanges(
// Extract relevant buckets and merge them
relevantBuckets := bc.filterRelevantBuckets(data.Buckets, startMs, endMs)
if len(relevantBuckets) == 0 {
return nil, missing
// nothing cached overlaps the window, so all of it has to be fetched.
return nil, []*qbtypes.TimeRange{{From: startMs, To: endMs}}
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, data.Warnings)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, startMs, endMs)
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
return mergedResult, missing
}
@@ -208,8 +210,8 @@ func (bc *bucketCache) generateCacheKey(q qbtypes.Query) string {
// findMissingRangesWithStep identifies time ranges not covered by cached buckets with step alignment.
func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket, startMs, endMs uint64, stepMs uint64) []*qbtypes.TimeRange {
// When step is 0 or window is too small to be cached, use simple algorithm
if stepMs == 0 || (startMs+stepMs) > endMs {
// When step is 0, use simple algorithm
if stepMs == 0 {
return bc.findMissingRangesBasic(buckets, startMs, endMs)
}
@@ -304,7 +306,8 @@ func (bc *bucketCache) findMissingRangesWithStep(buckets []*qbtypes.CachedBucket
// Update current position to the end of this bucket
// But ensure it's aligned to step boundary
bucketEnd := min(bucket.EndMs, endMs)
if bucketEnd%stepMs != 0 && bucketEnd < endMs {
// The step the window ends inside is missing however far the bucket runs.
if bucketEnd%stepMs != 0 {
// Round down to step boundary
bucketEnd = bucketEnd - (bucketEnd % stepMs)
}
@@ -791,12 +794,26 @@ func max(a, b uint64) uint64 {
return b
}
// filterResultToTimeRange filters the result to only include values within the requested time range.
func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs, endMs uint64) *qbtypes.Result {
// filterResultToTimeRange narrows the cached result to the requested window, both
// the values in it and the heatmap axis under them.
func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes.Query, startMs, endMs, stepMs uint64) *qbtypes.Result {
if result == nil || result.Value == nil {
return result
}
_, isPromQL := q.(*promqlQuery)
maxTimestampMs := endMs
// A promql value at T is the query evaluated at T, so T == endMs is inside the
// requested range. For every other query type the value at T aggregates
// [T, T+stepMs), which the requested range contains only when T <= endMs-stepMs.
if !isPromQL {
if stepMs > 0 {
maxTimestampMs = endMs - stepMs
} else {
maxTimestampMs = endMs - 1
}
}
switch result.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
@@ -821,7 +838,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
// Filter values to only include those within the requested time range
for _, value := range series.Values {
timestampMs := uint64(value.Timestamp)
if timestampMs >= startMs && timestampMs < endMs {
if timestampMs >= startMs && timestampMs <= maxTimestampMs {
filteredSeries.Values = append(filteredSeries.Values, value)
}
}
@@ -836,6 +853,8 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
}
}
bc.trimHeatmapAxisToTheWindow(q, filteredData)
// Create a new result with the filtered data
return &qbtypes.Result{
Type: result.Type,
@@ -849,3 +868,25 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
// For non-time series data, return as is
return result
}
// A cache entry can hold a longer time range than the one now requested, and its
// bucket axis spans every sample value seen across that longer range. Once the
// points outside the requested range are dropped, the axis may still list
// buckets that only those dropped points counted into. Left there, they render
// as empty rows that the same request never has when it bypasses the cache.
//
// PromQL and ClickHouse queries name their own buckets, and a histogram's are
// its `le` labels, so only a gauge, sum or summary axis is cut.
func (bc *bucketCache) trimHeatmapAxisToTheWindow(q qbtypes.Query, tsData *qbtypes.TimeSeriesData) {
metricQuery, isMetricQuery := q.(*builderQuery[qbtypes.MetricAggregation])
if !isMetricQuery {
return
}
if metricQuery.spec.Aggregations[0].Type == metrictypes.HistogramType {
return
}
for _, aggBucket := range tsData.Aggregations {
aggBucket.TrimAxisToCountedBuckets()
}
}

View File

@@ -327,7 +327,7 @@ func BenchmarkBucketCache_FilterResultToTimeRange(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
filtered := bc.filterResultToTimeRange(result, startMs, endMs)
filtered := bc.filterResultToTimeRange(result, &promqlQuery{}, startMs, endMs, 0)
_ = filtered
}
})

View File

@@ -3,6 +3,7 @@ package querier
import (
"context"
"fmt"
"log/slog"
"testing"
"time"
@@ -1069,8 +1070,11 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
// Get cached data - should be filtered to requested range
cached, missing := bc.GetMissRanges(ctx, orgID, query2, qbtypes.Step{Duration: 1000 * time.Millisecond})
// Should have no missing ranges
assert.Len(t, missing, 0)
// The value at 3000 stands for the whole step to 4000, which reaches past the
// window, so it is left to be recomputed as a partial rather than served.
require.Len(t, missing, 1)
assert.Equal(t, uint64(3000), missing[0].From)
assert.Equal(t, uint64(3500), missing[0].To)
assert.NotNil(t, cached)
// Verify the cached result only contains values within the requested range
@@ -1080,18 +1084,65 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
require.Len(t, tsData.Aggregations[0].Series, 1)
series := tsData.Aggregations[0].Series[0]
assert.Len(t, series.Values, 2) // Only values at 2000 and 3000 should be included
require.Len(t, series.Values, 1)
// Verify the exact values
assert.Equal(t, int64(2000), series.Values[0].Timestamp)
assert.Equal(t, float64(20), series.Values[0].Value)
assert.Equal(t, int64(3000), series.Values[1].Timestamp)
assert.Equal(t, float64(30), series.Values[1].Value)
// Value at 1000 should not be included (before requested range)
// Value at 4000 should not be included (after requested range)
}
// A promql value is the query evaluated at a single moment rather than over a
// span, so the one at the window's end belongs to it and has to survive caching.
func TestBucketCache_PromQLKeepsTheValueAtTheWindowEnd(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Minute}
query := &promqlQuery{
logger: slog.Default(),
query: qbtypes.PromQuery{Query: "up", Step: step},
tr: qbtypes.TimeRange{From: 600_000, To: 780_000},
requestType: qbtypes.RequestTypeTimeSeries,
}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Series: []*qbtypes.TimeSeries{{
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 600_000, Value: 1},
{Timestamp: 660_000, Value: 2},
{Timestamp: 720_000, Value: 3},
{Timestamp: 780_000, Value: 4},
},
}},
}},
},
})
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Empty(t, missing)
require.NotNil(t, cached)
tsData, ok := cached.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)
timestamps := []int64{}
for _, value := range tsData.Aggregations[0].Series[0].Values {
timestamps = append(timestamps, value.Timestamp)
}
assert.Equal(t, []int64{600_000, 660_000, 720_000, 780_000}, timestamps)
}
func TestBucketCache_FindMissingRangesWithStep(t *testing.T) {
bc := createTestBucketCache(t)
@@ -1150,7 +1201,20 @@ func TestBucketCache_FindMissingRangesWithStep(t *testing.T) {
expectedMiss: []*qbtypes.TimeRange{
{From: 1000, To: 1500},
},
description: "Window smaller than step should use basic algorithm",
description: "Window smaller than step is fetched whole",
},
{
name: "small_window_less_than_step_inside_a_bucket",
buckets: []*qbtypes.CachedBucket{
{StartMs: 0, EndMs: 3000},
},
startMs: 1000,
endMs: 1500,
stepMs: 1000,
expectedMiss: []*qbtypes.TimeRange{
{From: 1000, To: 1500},
},
description: "A cached point spans its whole step, so it cannot answer a window shorter than the step",
},
{
name: "zero_step_uses_basic_algorithm",

View File

@@ -22,7 +22,7 @@ import (
const promHistogramBucketLabel = "le"
// cumulativeColumn maps a bucket's upper bound to the cumulative count at it.
// Differencing turns it into the per-band counts a heatmapColumn holds.
// Differencing turns it into the per-bucket counts a heatmapColumn holds.
type cumulativeColumn map[float64]float64
// promHeatmapGroup assembles one group across the several matrix series its `le`
@@ -34,8 +34,8 @@ type promHeatmapGroup struct {
}
// foldMatrixAsHeatmap folds a matrix of one cumulative series per (group, `le`)
// into one series per group whose points hold a count per band.
func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, stepMs uint64, queryName string) (*qbv5.TimeSeriesData, error) {
// into one series per group whose points hold a count per bucket.
func foldMatrixAsHeatmap(matrix promql.Matrix, queryName string) (*qbv5.TimeSeriesData, error) {
groups, groupOrder := collectCumulativeGroups(matrix)
// An empty matrix is only ever the window having no data, but series that
@@ -53,11 +53,12 @@ func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, step
}
}
return accumulator.foldSeries(queryWindow, stepMs, queryName)
// a promql data point can never be partial, hence nil and 0 are sent here
return accumulator.foldSeries(nil, 0, queryName)
}
// collectCumulativeGroups reads the matrix into one group per label set. A series
// without `le` has no band to sit in, so an expression that dropped the label
// without `le` has no bucket to sit in, so an expression that dropped the label
// draws nothing.
func collectCumulativeGroups(matrix promql.Matrix) (groups map[string]*promHeatmapGroup, groupOrder []string) {
groups = map[string]*promHeatmapGroup{}

View File

@@ -16,7 +16,7 @@ import (
// The cache key is the fingerprint alone, so two request types over one
// expression must not produce the same one — a time series payload served to a
// heatmap request has no axis and reads back as a single collapsed band.
// heatmap request has no axis and reads back as a single collapsed bucket.
func TestFingerprintSeparatesHeatmapFromTimeSeries(t *testing.T) {
fingerprintFor := func(requestType qbv5.RequestType) string {
q := &promqlQuery{
@@ -50,7 +50,7 @@ func TestFoldMatrixAsHeatmapClampsADecreasingCumulativeCount(t *testing.T) {
},
}
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
data, err := foldMatrixAsHeatmap(matrix, "A")
require.NoError(t, err)
require.Len(t, data.Aggregations, 1)
@@ -76,7 +76,7 @@ func TestFoldMatrixAsHeatmapWidensTheBandOverAMissingUpperBound(t *testing.T) {
},
}
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
data, err := foldMatrixAsHeatmap(matrix, "A")
require.NoError(t, err)
require.Len(t, data.Aggregations, 1)

View File

@@ -115,6 +115,13 @@ func newPromqlQuery(
requestType qbv5.RequestType,
variables map[string]qbv5.VariableItem,
) *promqlQuery {
// promql evaluates at tr.From and every step after it, so two windows a few
// seconds apart ask for different instants and could never share a cache
// entry. Rounding both ends down to the step makes them the same query.
if stepMs := uint64(query.Step.Milliseconds()); stepMs > 0 && !query.SkipStepAlignment {
tr.From -= tr.From % stepMs
tr.To -= tr.To % stepMs
}
return &promqlQuery{
logger: logger,
promEngine: promEngine,
@@ -133,6 +140,12 @@ func (q *promqlQuery) Fingerprint() string {
return ""
}
// an unaligned window evaluates at instants no other window asks for, so
// there is nothing to share
if q.query.SkipStepAlignment {
return ""
}
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
q.logger.ErrorContext(context.TODO(), "failed render template variables", slog.String("query", q.query.Query))
@@ -352,7 +365,7 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
}
func (q *promqlQuery) toResultForHeatmap(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
tsData, err := foldMatrixAsHeatmap(matrix, &q.tr, uint64(q.query.Step.Milliseconds()), q.query.Name)
tsData, err := foldMatrixAsHeatmap(matrix, q.query.Name)
if err != nil {
return nil, err
}

View File

@@ -1,194 +0,0 @@
package ruler
import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
const (
attachedSide diffSide = iota
detachedSide
)
type diffSide int
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
if err != nil {
return nil, err
}
return defaultToAllRules(ids), nil
}}
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
if id == coretypes.WildCardSelectorString {
return coretypes.WildcardSelector(ctx, resource, id, orgID)
}
return coretypes.IDSelector(ctx, resource, id, orgID)
}
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, attachedSide)
}
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
return newDowntimeDiffExtractor(rulerService, detachedSide)
}
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ec.Request.Context(), scheduleID)
if err != nil {
return nil, err
}
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
if err != nil {
return nil, err
}
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
if side == detachedSide {
return detached, nil
}
return attached, nil
}}
}
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
return diffLinks(defaultToAllRules(stored), incoming)
}
func defaultToAllRules(ids []string) []string {
if len(ids) == 0 {
return []string{coretypes.WildCardSelectorString}
}
return ids
}
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
names, err := coretypes.BodyJSONArray("channels").Fn(ec)
if err != nil {
return nil, err
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), names)
}}
}
func NewUpdateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return newRoutePolicyDiffExtractor(alertmanagerService, attachedSide)
}
func NewUpdateRoutePolicyChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
return newRoutePolicyDiffExtractor(alertmanagerService, detachedSide)
}
func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager, side diffSide) coretypes.ResourceIDsExtractor {
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
if ec.Request == nil {
return nil, nil
}
ctx := ec.Request.Context()
orgID, err := orgIDFromContext(ctx)
if err != nil {
return nil, err
}
stored, err := alertmanagerService.GetRoutePolicyByID(ctx, mux.Vars(ec.Request)["id"])
if err != nil {
return nil, err
}
incoming, err := coretypes.BodyJSONArray("channels").Fn(ec)
if err != nil {
return nil, err
}
attached, detached := diffLinks(stored.Channels, incoming)
if side == detachedSide {
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), detached)
}
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), attached)
}}
}
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return valuer.UUID{}, err
}
return valuer.NewUUID(claims.OrgID)
}
func diffLinks(stored, incoming []string) (attached, detached []string) {
storedSet := make(map[string]struct{}, len(stored))
for _, id := range stored {
storedSet[id] = struct{}{}
}
incomingSet := make(map[string]struct{}, len(incoming))
for _, id := range incoming {
incomingSet[id] = struct{}{}
}
seen := make(map[string]struct{}, len(incoming))
attached = make([]string, 0, len(incoming))
for _, id := range incoming {
if _, ok := storedSet[id]; ok {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
attached = append(attached, id)
}
clear(seen)
detached = make([]string, 0, len(stored))
for _, id := range stored {
if _, ok := incomingSet[id]; ok {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
detached = append(detached, id)
}
return attached, detached
}

View File

@@ -1,160 +0,0 @@
package ruler
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDiffLinks(t *testing.T) {
testCases := []struct {
name string
stored []string
incoming []string
wantAttached []string
wantDetached []string
}{
{
name: "BothEmpty_NothingChanges",
wantAttached: []string{},
wantDetached: []string{},
},
{
name: "NoStored_EverythingAttaches",
incoming: []string{"slack", "pager"},
wantAttached: []string{"slack", "pager"},
wantDetached: []string{},
},
{
name: "NoIncoming_EverythingDetaches",
stored: []string{"slack", "pager"},
wantAttached: []string{},
wantDetached: []string{"slack", "pager"},
},
{
name: "Unchanged_NothingChanges",
stored: []string{"slack", "pager"},
incoming: []string{"pager", "slack"},
wantAttached: []string{},
wantDetached: []string{},
},
{
name: "OneAddedOneRemoved_BothSidesReported",
stored: []string{"slack", "pager"},
incoming: []string{"slack", "email"},
wantAttached: []string{"email"},
wantDetached: []string{"pager"},
},
{
name: "DuplicateIncoming_CollapsedOnce",
incoming: []string{"slack", "slack"},
wantAttached: []string{"slack"},
wantDetached: []string{},
},
{
name: "DuplicateStored_CollapsedOnce",
stored: []string{"pager", "pager"},
wantAttached: []string{},
wantDetached: []string{"pager"},
},
{
name: "IncomingOrderPreserved",
stored: []string{"keep"},
incoming: []string{"zebra", "keep", "apple"},
wantAttached: []string{"zebra", "apple"},
wantDetached: []string{},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attached, detached := diffLinks(testCase.stored, testCase.incoming)
assert.Equal(t, testCase.wantAttached, attached)
assert.Equal(t, testCase.wantDetached, detached)
})
}
}
func TestDiffDowntimeRuleLinks(t *testing.T) {
testCases := []struct {
name string
stored []string
incoming []string
wantAttached []string
wantDetached []string
}{
{
name: "AllToAll_NoChange",
stored: nil,
incoming: []string{"*"},
wantAttached: []string{},
wantDetached: []string{},
},
{
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
stored: nil,
incoming: []string{"r1"},
wantAttached: []string{"r1"},
wantDetached: []string{"*"},
},
{
name: "WidenFromOneToAll_DetachesOneAttachesAll",
stored: []string{"r1"},
incoming: []string{"*"},
wantAttached: []string{"*"},
wantDetached: []string{"r1"},
},
{
name: "SwapRules_DiffsIDs",
stored: []string{"r1"},
incoming: []string{"r2"},
wantAttached: []string{"r2"},
wantDetached: []string{"r1"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
assert.ElementsMatch(t, testCase.wantAttached, attached)
assert.ElementsMatch(t, testCase.wantDetached, detached)
})
}
}
func TestDowntimeRuleIDsExtractor(t *testing.T) {
testCases := []struct {
name string
body string
wantIDs []string
}{
{
name: "MissingAlertIds_AllRules",
body: `{"name": "window"}`,
wantIDs: []string{"*"},
},
{
name: "EmptyAlertIds_AllRules",
body: `{"alertIds": []}`,
wantIDs: []string{"*"},
},
{
name: "ExplicitAlertIds_Kept",
body: `{"alertIds": ["r1", "r2"]}`,
wantIDs: []string{"r1", "r2"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.wantIDs, ids)
})
}
}

View File

@@ -100,12 +100,10 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ rulestatehistory.Handler }{},
struct{ spanmapper.Handler }{},
struct{ alertmanager.Handler }{},
struct{ alertmanager.Alertmanager }{},
struct{ prometheus.Handler }{},
struct{ llmpricingrule.Handler }{},
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
struct{ ruler.Ruler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
global.Config{},

View File

@@ -48,7 +48,6 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
@@ -266,9 +265,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewAddGCPConnectionMethodFactory(),
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
sqlmigration.NewDeleteOrphanSARolesFactory(),
)
}
@@ -333,7 +330,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -370,12 +367,10 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RuleStateHistory,
handlers.SpanMapperHandler,
handlers.AlertmanagerHandler,
alertmanagerService,
handlers.PrometheusHandler,
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
rulerService,
handlers.StatsHandler,
handlers.SavedView,
globalConfig,

View File

@@ -106,8 +106,6 @@ func TestNewProviderFactories(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
)
})
}

View File

@@ -622,7 +622,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web),
"signoz",
)
if err != nil {

View File

@@ -1,173 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRoutePolicyTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddRoutePolicyTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_route_policy_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRoutePolicyTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addRoutePolicyTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRoutePolicyTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
linkVerbs := []string{"attach", "detach"}
tuples := make([]migrationTuple, 0, 15)
for _, verb := range crudVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "route-policy", verb})
}
for _, verb := range readVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "route-policy", verb},
migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "route-policy", verb},
)
}
for _, verb := range linkVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "route-policy", verb},
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "notification-channel", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "notification-channel", verb},
)
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addRoutePolicyTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,74 @@
package sqlmigration
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type deleteOrphanSARoles struct{}
type serviceAccount139 struct {
bun.BaseModel `bun:"table:service_account"`
ID string `bun:"id,pk"`
Status string `bun:"status"`
}
type serviceAccountRole139 struct {
bun.BaseModel `bun:"table:service_account_role"`
ServiceAccountID string `bun:"service_account_id"`
}
func NewDeleteOrphanSARolesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("delete_orphan_sa_roles"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deleteOrphanSARoles{}, nil
},
)
}
func (migration *deleteOrphanSARoles) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *deleteOrphanSARoles) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var deletedServiceAccountIDs []string
err = tx.NewSelect().
Model(new(serviceAccount139)).
Column("id").
Where("status = ?", "deleted").
Scan(ctx, &deletedServiceAccountIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
if len(deletedServiceAccountIDs) > 0 {
_, err = tx.NewDelete().
Model(new(serviceAccountRole139)).
Where("service_account_id IN (?)", bun.In(deletedServiceAccountIDs)).
Exec(ctx)
if err != nil {
return err
}
}
return tx.Commit()
}
func (migration *deleteOrphanSARoles) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,174 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addDowntimeTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDowntimeTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
linkVerbs := []string{"attach", "detach"}
tuples := make([]migrationTuple, 0, 20)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
}
for _, objectName := range []string{"planned-maintenance", "rule"} {
for _, verb := range linkVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
)
}
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,140 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRuleTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddRuleTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_rule_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRuleTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addRuleTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRuleTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
tuples := make([]migrationTuple, 0, 12)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "rule", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "rule", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "rule", verb})
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addRuleTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -150,24 +150,6 @@ func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, c
return cfg, nil
}
func MapChannelNamesToIDs(channels Channels, names []string) ([]string, error) {
idByName := make(map[string]string, len(channels))
for _, channel := range channels {
idByName[channel.DisplayName] = channel.ID.StringValue()
}
ids := make([]string, 0, len(names))
for _, name := range names {
id, ok := idByName[name]
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "notification channel %q does not exist", name)
}
ids = append(ids, id)
}
return ids, nil
}
func NewStatsFromChannels(channels Channels) map[string]any {
stats := make(map[string]any)
for _, channel := range channels {

View File

@@ -6,12 +6,9 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewConfigFromChannels(t *testing.T) {
@@ -299,36 +296,3 @@ func TestNewChannelFromReceiverGoogleChat(t *testing.T) {
channel.Data,
)
}
func TestMapChannelNamesToIDs(t *testing.T) {
slackID := valuer.GenerateUUID()
pagerID := valuer.GenerateUUID()
channels := Channels{
{Identifiable: types.Identifiable{ID: slackID}, DisplayName: "slack"},
{Identifiable: types.Identifiable{ID: pagerID}, DisplayName: "pager"},
}
testCases := []struct {
name string
names []string
want []string
wantErr bool
}{
{name: "AllKnown_IDsInRequestOrder", names: []string{"pager", "slack"}, want: []string{pagerID.StringValue(), slackID.StringValue()}},
{name: "UnknownName_Errors", names: []string{"slack", "missing"}, wantErr: true},
{name: "NoNames_Empty", names: nil, want: []string{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got, err := MapChannelNamesToIDs(channels, testCase.names)
if testCase.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.want, got)
})
}
}

View File

@@ -134,16 +134,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
@@ -176,16 +172,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
// route-policy — admin writes, viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
// apdex-setting — admin updates, viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
@@ -249,16 +241,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
// rule — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
// saved-view — full CRUD
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
@@ -294,8 +282,6 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// notification-channel — read only (admin writes)
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
// route-policy — read only (admin writes)
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},

View File

@@ -48,7 +48,7 @@ var (
ResourceRole = NewResourceRole()
ResourceServiceAccount = NewResourceServiceAccount()
ResourceUser = NewResourceUser()
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel)
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceRoutePolicy = NewResourceMetaResource(KindRoutePolicy)
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)

View File

@@ -11,6 +11,9 @@ type PromQuery struct {
Step Step `json:"step"`
// stats if true, the query will return stats
Stats bool `json:"stats"`
// skipStepAlignment if true, start and end are used as given instead of
// rounded down to the step, and the result is never cached
SkipStepAlignment bool `json:"skipStepAlignment,omitempty"`
Legend string `json:"legend"`
}

View File

@@ -189,6 +189,51 @@ func (a *AggregationBucket) ReindexValuesToNewUpperBounds(onto []float64) {
a.Meta.Buckets = onto
}
// TrimAxisToCountedBuckets drops the buckets at either end of Meta.Buckets that hold
// no counts, since an axis runs from the lowest value in the window to the highest.
// Call it only when the buckets were chosen from the values. A histogram's `le`
// buckets are fixed, so an empty one of those still has to stay on the axis.
func (a *AggregationBucket) TrimAxisToCountedBuckets() {
if a == nil || len(a.Meta.Buckets) == 0 {
return
}
lowestCounted, highestCounted := len(a.Meta.Buckets), -1
for _, series := range a.Series {
for _, point := range series.Values {
for slot := 0; slot < len(a.Meta.Buckets) && slot < len(point.Values); slot++ {
if point.Values[slot] != 0 {
lowestCounted = min(lowestCounted, slot)
highestCounted = max(highestCounted, slot)
}
}
}
}
if highestCounted < 0 {
return
}
if lowestCounted == 0 && highestCounted == len(a.Meta.Buckets)-1 {
return
}
trimmed := a.Meta.Buckets[lowestCounted : highestCounted+1]
for _, series := range a.Series {
for _, point := range series.Values {
if len(point.Values) == 0 {
continue
}
counts := make([]float64, len(trimmed)+1)
for slot, count := range point.Values {
counts[min(max(slot-lowestCounted, 0), len(trimmed))] += count
}
point.Values = counts
}
}
a.Meta.Buckets = trimmed
}
type AggregationMeta struct {
Unit string `json:"unit,omitempty"`
// Buckets holds ascending upper bounds shared by every series in the

View File

@@ -269,6 +269,7 @@ type Store interface {
CreateServiceAccountRole(context.Context, *ServiceAccountRole) error
GetServiceAccountRoleByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*ServiceAccountRole, error)
DeleteServiceAccountRole(context.Context, valuer.UUID, valuer.UUID) error
DeleteServiceAccountRolesByServiceAccountID(context.Context, valuer.UUID) error
// Service Account Factor API Key
CreateFactorAPIKey(context.Context, *FactorAPIKey) error

View File

@@ -5,7 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
brace-expansion@>=5.0.0 <5.0.9: '>=5.0.9 <6'
brace-expansion@>=5.0.0 <5.0.12: '>=5.0.12 <6'
importers:
@@ -396,8 +396,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
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}
cross-spawn@7.0.6:
@@ -878,7 +878,7 @@ snapshots:
balanced-match@4.0.4: {}
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -1031,7 +1031,7 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
ms@2.1.3: {}

View File

@@ -3,4 +3,4 @@
overrides:
# via: eslint-plugin-playwright > eslint@10 > minimatch@10.2.5 (brace-expansion ^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'

View File

@@ -188,9 +188,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -201,9 +199,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -260,9 +256,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -273,9 +267,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -424,9 +416,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -437,9 +427,7 @@
"read",
"update",
"delete",
"list",
"attach",
"detach"
"list"
]
},
{
@@ -504,9 +492,7 @@
"kind": "notification-channel",
"verbs": [
"read",
"list",
"attach",
"detach"
"list"
]
},
{

View File

@@ -51,7 +51,9 @@ def test_upstream_promqltest_corpus(
req_start_ms = start_ms - step_s * 1000
query = {
"type": "promql",
"spec": {"name": "A", "query": case["expr"], "step": step_s},
# the corpus expects points at exactly start plus whole steps, as
# Prometheus itself evaluates them
"spec": {"name": "A", "query": case["expr"], "step": step_s, "skipStepAlignment": True},
}
case_id = f"{case['source']}[{case['variant']}]"

View File

@@ -73,7 +73,7 @@ def test_identical_labelsets_merge_and_grouping(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def run(promql: str, start_ms: int, end_ms: int) -> list[tuple[dict, list]]:
q = {"type": "promql", "spec": {"name": "A", "query": promql, "step": 60}}
q = {"type": "promql", "spec": {"name": "A", "query": promql, "step": 60, "skipStepAlignment": True}}
resp = make_query_request(signoz, token, start_ms, end_ms, [q])
assert resp.status_code == HTTPStatus.OK, f"{promql!r}: {resp.text[:300]}"
out = []

View File

@@ -58,8 +58,7 @@ def test_promql_ratio_with_zero_denominator_is_dropped_and_cached(
assert set(first["active_job"].values()) == {25.0}, sorted(set(first["active_job"].values()))
assert len(first["active_job"]) == expected_points, f"expected {expected_points} points, got {len(first['active_job'])}"
# The cached read excludes end_ms, the one legitimate difference.
# Both reads must agree exactly, including the point promql reports at end_ms.
assert set(second) == set(first), sorted(second)
for job_name, points in first.items():
expected = {ts: value for ts, value in points.items() if ts < end_ms}
assert second[job_name] == expected, f"{job_name}: got {len(second[job_name])} of {len(expected)} points"
assert second[job_name] == points, f"{job_name}: got {len(second[job_name])} of {len(points)} points"

View File

@@ -40,7 +40,7 @@ def test_promql_subquery_without_step_evaluates(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])", "skipStepAlignment": True}}
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text[:300]
series = get_all_series(response.json(), "A")
@@ -54,6 +54,6 @@ def test_promql_subquery_without_step_evaluates(
token,
start_ms,
end_ms,
[{"type": "promql", "spec": {"name": "A", "query": metric}}],
[{"type": "promql", "spec": {"name": "A", "query": metric, "skipStepAlignment": True}}],
)
assert response.status_code == HTTPStatus.OK, response.text[:300]

View File

@@ -72,7 +72,7 @@ def test_or_arms_merge_under_subquery_name_drop(
assert set(instant_values.values()) == {1.0}, instant_values
# query_range over the same grid must agree point for point.
spec = {"name": "A", "query": query, "step": 300}
spec = {"name": "A", "query": query, "step": 300, "skipStepAlignment": True}
response = make_query_request(signoz, token, start_ms, end_ms, [{"type": "promql", "spec": spec}])
assert response.status_code == HTTPStatus.OK, response.text[:300]
series = get_all_series(response.json(), "A")

View File

@@ -0,0 +1,444 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
assert_results_equal,
build_builder_query,
get_series_values,
make_query_request,
)
MINUTE_MS = 60_000
def test_builder_shortening_the_time_range_at_the_end(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
# the cache outlives the run, so a fixed name would serve the previous run's
# points back to this one
metric_name = f"cache_end_shortened_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 5m step makes the base query span
# two whole steps, so both its points are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms_base_query = start_time_ms + 10 * MINUTE_MS
end_time_ms_shortened_query = start_time_ms + 7 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300)]
# the 5m step splits the ten minutes into two points, each the max over its
# own step: minutes 0-4 and minutes 5-9. The second changes partway through,
# 256 until minute 7 and then 4096, so ending the range at minute 7 has to
# reach a different value than ending it at minute 10
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(16, 16, 16, 16, 16, 256, 256, 4096, 4096, 4096)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms, end_time_ms_base_query, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(16, False), (4096, False)]
from_cache = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "shortened end")
# the shortened end reaches only minutes 5-6 of the second point, so it comes
# back as 256 and partial, where the cached one spans all five minutes
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(16, False), (256, True)], label
def test_builder_shortening_the_time_range_at_the_start(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_start_shortened_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 5m step makes the base query span
# two whole steps, so both its points are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms_base_query = int(start_time.timestamp() * 1000)
start_time_ms_shortened_query = start_time_ms_base_query + 3 * MINUTE_MS
end_time_ms = start_time_ms_base_query + 10 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300)]
# the 5m step splits the ten minutes into two points, each the max over its
# own step: minutes 0-4 and minutes 5-9. Only minute 0 holds 65536, so a first
# point reaching it says the whole step was read even though the shortened
# range opens at minute 3
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(65536, 16, 16, 16, 16, 4096, 4096, 4096, 4096, 4096)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms_base_query, end_time_ms, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(65536, False), (4096, False)]
from_cache = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "shortened start")
# starting inside the first point's step flags that point partial without
# clipping its value, which still covers the whole step and so reaches the
# 65536 at minute 0
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(65536, True), (4096, False)], label
def test_builder_time_range_shorter_than_the_step(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_range_below_step_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 10m step makes the base query span
# two whole steps, so both its points are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 600 * 600, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms_base_query = start_time_ms + 20 * MINUTE_MS
start_time_ms_short_query = start_time_ms + 10 * MINUTE_MS
end_time_ms_short_query = start_time_ms + 14 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=600)]
# the 10m step splits the twenty minutes into two points, each the max over
# its own step: minutes 0-9 and minutes 10-19. The second changes partway
# through, 256 until minute 14 and then 4096, so a four minute range opening
# at minute 10 has to reach a different value than the whole step does
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 256, 256, 256, 256, 4096, 4096, 4096, 4096, 4096, 4096)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(20)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms, end_time_ms_base_query, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(16, False), (4096, False)]
from_cache = make_query_request(signoz, token, start_time_ms_short_query, end_time_ms_short_query, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms_short_query, end_time_ms_short_query, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "range shorter than the step")
# a range shorter than its step holds a single point, and this one reaches
# only minutes 10-13 of the second step, so it comes back as 256 and partial,
# where the cached point spans all ten minutes
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["value"], point.get("partial", False)) for point in points]
assert returned_points == [(256, True)], label
def test_promql_running_the_same_query_twice(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_repeat_total_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms = start_time_ms + 2 * MINUTE_MS
query = [{"type": "promql", "spec": {"name": "A", "query": f"sum(increase({metric_name}[2m]))", "step": 60}}]
# the counter opens a minute before the query so its first point has something
# to increase over, and starts far above its own rise across the range, below
# which increase clips its back-extrapolation at the counter's zero point. It
# rises by a different amount each minute, so every point is its own number
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(1000, 1010, 1030, 1060, 1100)[minute + 1],
temporality="Cumulative",
type_="Sum",
is_monotonic=True,
)
for minute in range(-1, 4)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = make_query_request(signoz, token, start_time_ms, end_time_ms, query, no_cache=False)
assert first.status_code == HTTPStatus.OK, first.text
second = make_query_request(signoz, token, start_time_ms, end_time_ms, query, no_cache=False)
assert second.status_code == HTTPStatus.OK, second.text
assert_results_equal(first.json(), second.json(), "A", "the same query twice")
# promql reports a point at the instant the range closes, and the second run,
# answered out of what the first one cached, has to keep it
for run, response in (("first", first), ("second", second)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"]) for point in points]
## at each timestamp t, promql looks at points in (t-2minutes, t].
assert returned_points == [
(start_time_ms, 20), # t = 0, points taken 1000, 1010. hence diff over 1m is 10, extrapolated to 20.
(start_time_ms + MINUTE_MS, 40), # t = 1m, points taken 1010, 1030. hence diff over 1m is 20, extrapolated to 40.
(end_time_ms, 60), # t = 2m, points taken 1030, 1060. hence diff over 1m is 30, extrapolated to 60.
], f"{run} run"
def test_promql_shifting_the_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_shift_gauge_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a whole minute is what makes the first query aligned
# to its 1m step, and the unaligned one half a step off it
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
aligned_start_time_ms = int(start_time.timestamp() * 1000)
aligned_end_time_ms = aligned_start_time_ms + 3 * MINUTE_MS
unaligned_start_time_ms = aligned_start_time_ms + MINUTE_MS // 2
unaligned_end_time_ms = aligned_end_time_ms + MINUTE_MS // 2
query = [{"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric_name}[2m])", "step": 60}}]
# a sample every 30s, rising by 100 each time. The two queries report 30s
# apart, so they land on different samples and share no value between them
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(seconds=30 * half_minute),
value=100 * (half_minute + 4),
type_="Gauge",
is_monotonic=False,
)
for half_minute in range(-3, 8)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
aligned_and_cached = make_query_request(signoz, token, aligned_start_time_ms, aligned_end_time_ms, query, no_cache=False)
assert aligned_and_cached.status_code == HTTPStatus.OK, aligned_and_cached.text
## at each timestamp t, promql takes the highest sample in (t-2minutes, t],
## which is the one at t itself since the gauge only rises.
aligned_points = [
(aligned_start_time_ms, 400), # t = 0
(aligned_start_time_ms + MINUTE_MS, 600), # t = 1m
(aligned_start_time_ms + 2 * MINUTE_MS, 800), # t = 2m
(aligned_end_time_ms, 1000), # t = 3m
]
points = sorted(get_series_values(aligned_and_cached.json(), "A"), key=lambda point: point["timestamp"])
assert [(point["timestamp"], point["value"]) for point in points] == aligned_points
# an unaligned range is rounded down to the step, so this asks for the same
# instants as the aligned query and gets the same answer
unaligned_and_cached = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, no_cache=False)
assert unaligned_and_cached.status_code == HTTPStatus.OK, unaligned_and_cached.text
points = sorted(get_series_values(unaligned_and_cached.json(), "A"), key=lambda point: point["timestamp"])
assert [(point["timestamp"], point["value"]) for point in points] == aligned_points, "unaligned query, rounded to the step"
assert_results_equal(unaligned_and_cached.json(), aligned_and_cached.json(), "A", "unaligned query, rounded to the step")
# skipping the alignment keeps the range as asked, so promql reports 30s off
# the cached points, on samples the aligned query never saw. Such a query is
# never cached, so repeating it never gets the aligned answer either
unaligned_query = [{"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric_name}[2m])", "step": 60, "skipStepAlignment": True}}]
unaligned_points = [
(unaligned_start_time_ms, 500), # t = 30s
(unaligned_start_time_ms + MINUTE_MS, 700), # t = 1m30s
(unaligned_start_time_ms + 2 * MINUTE_MS, 900), # t = 2m30s
(unaligned_end_time_ms, 1100), # t = 3m30s
]
for run in ("first", "second"):
unaligned_as_asked = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, unaligned_query, no_cache=False)
assert unaligned_as_asked.status_code == HTTPStatus.OK, unaligned_as_asked.text
points = sorted(get_series_values(unaligned_as_asked.json(), "A"), key=lambda point: point["timestamp"])
assert [(point["timestamp"], point["value"]) for point in points] == unaligned_points, f"unaligned query as asked, {run} run"
def test_promql_time_range_shorter_than_the_step(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_promql_range_below_step_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 10m step aligns the base query to it
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 600 * 600, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms_base_query = start_time_ms + 10 * MINUTE_MS
start_time_ms_short_query = start_time_ms + 12 * MINUTE_MS
end_time_ms_short_query = start_time_ms + 14 * MINUTE_MS
query = [{"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric_name}[2m])", "step": 600}}]
# a sample every minute, rising by 100 each time, so every instant promql
# reports at has its own value
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=100 * (minute + 1),
type_="Gauge",
is_monotonic=False,
)
for minute in range(15)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms, end_time_ms_base_query, query, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
points = sorted(get_series_values(base_query.json(), "A"), key=lambda point: point["timestamp"])
assert [(point["timestamp"], point["value"]) for point in points] == [(start_time_ms, 100), (end_time_ms_base_query, 1100)]
# a range shorter than the step rounds down to the single instant at minute
# 10, the instant the base query closed on
from_cache = make_query_request(signoz, token, start_time_ms_short_query, end_time_ms_short_query, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms_short_query, end_time_ms_short_query, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "range shorter than the step")
for label, response in (("from cache", from_cache), ("uncached", uncached)):
points = sorted(get_series_values(response.json(), "A"), key=lambda point: point["timestamp"])
assert [(point["timestamp"], point["value"]) for point in points] == [(end_time_ms_base_query, 1100)], label
def test_builder_refreshing_a_sliding_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_sliding_{uuid4().hex[:8]}"
# 90 minutes back so even the twentieth refresh closes clear of the flux
# interval, which holds recent data out of the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max")]
# the 1m step gives one point per seeded minute, and a value no other minute
# carries, so a point stitched in from the wrong range reads as the wrong minute
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=1000 + minute,
type_="Gauge",
is_monotonic=False,
)
for minute in range(80)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# a dashboard left open on a one hour range, re-running a minute later each time
for refresh in range(20):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
# each refresh is stitched out of overlapping cached ranges, so this catches
# a point served twice, dropped, or carried over from an earlier refresh
points = sorted(get_series_values(from_cache.json(), "A"), key=lambda point: point["timestamp"])
returned_points = [(point["timestamp"], point["value"], point.get("partial", False)) for point in points]
expected_points = [(start_time_ms + minute * MINUTE_MS, 1000 + minute, False) for minute in range(refresh, refresh + 60)]
assert returned_points == expected_points, f"refresh {refresh} did not return the minutes it covers"
last_refresh_start_ms = start_time_ms + 19 * MINUTE_MS
uncached = make_query_request(signoz, token, last_refresh_start_ms, last_refresh_start_ms + 60 * MINUTE_MS, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")

View File

@@ -0,0 +1,546 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
RequestType,
assert_identical_query_response,
build_builder_query,
build_linear_bucket_options,
get_heatmap_buckets,
get_heatmap_columns,
make_query_request,
)
MINUTE_MS = 60_000
@pytest.mark.parametrize(
"first_minute, expected_buckets",
[
pytest.param(0, [100, 200], id="the_lower_half"),
pytest.param(5, [800, 900], id="the_upper_half"),
],
)
def test_builder_narrowing_to_half_the_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
first_minute: int,
expected_buckets: list[int],
) -> None:
metric_name = f"heatmap_cache_narrowed_{uuid4().hex[:8]}"
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms = start_time_ms + 10 * MINUTE_MS
# 100 wide buckets, and the first five minutes sit seven buckets under the
# last five, so the axis over all ten covers a stretch neither half reaches
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=150 if minute < 5 else 850,
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = [build_builder_query("A", metric_name, "max", "max", bucket_options=build_linear_bucket_options(1000, 10))]
# the whole range first, which is what puts its axis in the cache
whole_range = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert whole_range.status_code == HTTPStatus.OK, whole_range.text
assert get_heatmap_buckets(whole_range.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
assert [column["values"] for column in get_heatmap_columns(whole_range.json(), "A")] == [
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
]
half_start_ms = start_time_ms + first_minute * MINUTE_MS
half_end_ms = half_start_ms + 5 * MINUTE_MS
from_cache = make_query_request(signoz, token, half_start_ms, half_end_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, half_start_ms, half_end_ms, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
for source, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx(expected_buckets), source
assert [column["values"] for column in get_heatmap_columns(response.json(), "A")] == [
[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
], source
assert_identical_query_response(from_cache, uncached)
def test_builder_narrowing_a_histogram(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_histogram_{uuid4().hex[:8]}_bucket"
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms = start_time_ms + 10 * MINUTE_MS
# the count each `le` reports every minute, cumulative across `le` as a
# histogram is. For the first five minutes the ten arrivals are all at or
# below 1, for the last five they are all between 4 and 8, and the buckets
# holding none of them report a count of 0 rather than going unreported
le_to_counts = {
"1": [10, 10, 10, 10, 10, 0, 0, 0, 0, 0],
"2": [10, 10, 10, 10, 10, 0, 0, 0, 0, 0],
"4": [10, 10, 10, 10, 10, 0, 0, 0, 0, 0],
"8": [10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
"+Inf": [10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
}
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"le": le},
timestamp=start_time + timedelta(minutes=minute),
value=count,
temporality="Delta",
type_="Histogram",
)
for le, counts in le_to_counts.items()
for minute, count in enumerate(counts)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = [build_builder_query("A", metric_name, "increase", "p50", temporality="delta", group_by=["le"])]
# the whole range first, which is what puts its axis in the cache
whole_range = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert whole_range.status_code == HTTPStatus.OK, whole_range.text
assert get_heatmap_buckets(whole_range.json(), "A") == [1, 2, 4, 8]
assert [column["values"] for column in get_heatmap_columns(whole_range.json(), "A")] == [
[10, 0, 0, 0, 0],
[10, 0, 0, 0, 0],
[10, 0, 0, 0, 0],
[10, 0, 0, 0, 0],
[10, 0, 0, 0, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
]
# even though this shortened time range has no data below 4, all histogram
# buckets are still returned back
half_start_ms = start_time_ms + 5 * MINUTE_MS
from_cache = make_query_request(signoz, token, half_start_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, half_start_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
for source, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == [1, 2, 4, 8], source
assert [column["values"] for column in get_heatmap_columns(response.json(), "A")] == [
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
[0, 0, 0, 10, 0],
], source
assert_identical_query_response(from_cache, uncached)
def test_builder_shortening_the_time_range_at_the_end(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_end_shortened_{uuid4().hex[:8]}"
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms_base_query = start_time_ms + 10 * MINUTE_MS
end_time_ms_shortened_query = start_time_ms + 7 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300, bucket_options=build_linear_bucket_options(1000, 10))]
# the 5m step splits the ten minutes into two columns, each the max over its
# own step: minutes 0-4 and minutes 5-9. The second changes partway through,
# 250 until minute 7 and then 850, which fall six buckets apart, so ending
# the range at minute 7 has to reach a different bucket than ending it at
# minute 10 and an axis that stops well below it
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(150, 150, 150, 150, 150, 250, 250, 850, 850, 850)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms, end_time_ms_base_query, query, request_type=RequestType.HEATMAP, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
# 100 wide buckets, and the two maxes are 150 and 850, so the axis runs from
# the bottom of (100, 200] to the top of (800, 900]
assert get_heatmap_buckets(base_query.json(), "A") == pytest.approx([100, 200, 300, 400, 500, 600, 700, 800, 900])
base_columns = get_heatmap_columns(base_query.json(), "A")
assert [column["values"] for column in base_columns] == [
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
]
assert [column.get("partial", False) for column in base_columns] == [False, False]
from_cache = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms, end_time_ms_shortened_query, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
# the shortened end reaches only minutes 5-6 of the second column, whose max
# is 250 and which comes back partial. Nothing in this window passes 300, so
# the axis stops there rather than carrying the buckets above it
for label, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx([100, 200, 300]), label
columns = get_heatmap_columns(response.json(), "A")
assert [column["values"] for column in columns] == [
[0, 1, 0, 0],
[0, 0, 1, 0],
], label
assert [column.get("partial", False) for column in columns] == [False, True], label
assert_identical_query_response(from_cache, uncached)
def test_builder_shortening_the_time_range_at_the_start(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_start_shortened_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a multiple of the 5m step makes the base query span
# two whole steps, so both its columns are complete
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 300 * 300, tz=UTC)
start_time_ms_base_query = int(start_time.timestamp() * 1000)
start_time_ms_shortened_query = start_time_ms_base_query + 3 * MINUTE_MS
end_time_ms = start_time_ms_base_query + 10 * MINUTE_MS
query = [build_builder_query("A", metric_name, "max", "max", step_interval=300, bucket_options=build_linear_bucket_options(1000, 10))]
# the 5m step splits the ten minutes into two columns, each the max over its
# own step: minutes 0-4 and minutes 5-9. Only minute 0 reaches 950, so a
# first column counted in (900, 1000] says the whole step was read even
# though the shortened range opens at minute 3
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=(950, 150, 150, 150, 150, 350, 350, 350, 350, 350)[minute],
type_="Gauge",
is_monotonic=False,
)
for minute in range(10)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
base_query = make_query_request(signoz, token, start_time_ms_base_query, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert base_query.status_code == HTTPStatus.OK, base_query.text
# 100 wide buckets, and the two maxes are 950 and 350, so the axis runs from
# the bottom of (300, 400] to the top of (900, 1000]
assert get_heatmap_buckets(base_query.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000])
base_columns = get_heatmap_columns(base_query.json(), "A")
assert [column["values"] for column in base_columns] == [
[0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0],
]
assert [column.get("partial", False) for column in base_columns] == [False, False]
from_cache = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, start_time_ms_shortened_query, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
# starting inside the first column's step flags that column partial without
# clipping its counts, which still cover the whole step and so reach the 950
# at minute 0, leaving the axis where the base query drew it
for label, response in (("uncached", uncached), ("from cache", from_cache)):
assert get_heatmap_buckets(response.json(), "A") == pytest.approx([300, 400, 500, 600, 700, 800, 900, 1000]), label
columns = get_heatmap_columns(response.json(), "A")
assert [column["values"] for column in columns] == [
[0, 0, 0, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 0, 0, 0],
], label
assert [column.get("partial", False) for column in columns] == [True, False], label
assert_identical_query_response(from_cache, uncached)
def test_builder_refreshing_a_sliding_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_sliding_{uuid4().hex[:8]}"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max", bucket_options=build_linear_bucket_options(1000, 10))]
# the 1m step gives one column per seeded minute, and 100 wide buckets give
# every minute a bucket no other minute reaches, so a column stitched in from
# the wrong range is counted in the wrong bucket
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=100 * minute + 50,
type_="Gauge",
is_monotonic=False,
)
for minute in range(7)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# the window slides onto a bucket a minute higher each refresh, so an axis
# carried over from an earlier one is off by as many buckets
expected_buckets_by_refresh = [
[0, 100, 200, 300, 400],
[100, 200, 300, 400, 500],
[200, 300, 400, 500, 600],
[300, 400, 500, 600, 700],
]
# whichever four minutes a refresh reads, each is in a bucket of its own and
# they arrive in order, so the counts run down the diagonal
expected_columns = [
[0, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
]
# a dashboard left open on a four minute range, re-running a minute later each
# time, so every refresh is stitched out of the ranges the ones before it cached
for refresh, expected_buckets in enumerate(expected_buckets_by_refresh):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 4 * MINUTE_MS, query, request_type=RequestType.HEATMAP, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
assert get_heatmap_buckets(from_cache.json(), "A") == pytest.approx(expected_buckets), f"refresh {refresh}"
# a column served twice, dropped, or carried over from an earlier refresh
# breaks the diagonal or the run of timestamps
columns = get_heatmap_columns(from_cache.json(), "A")
assert [column["timestamp"] for column in columns] == [
refresh_start_ms,
refresh_start_ms + MINUTE_MS,
refresh_start_ms + 2 * MINUTE_MS,
refresh_start_ms + 3 * MINUTE_MS,
], f"refresh {refresh}"
assert [column["values"] for column in columns] == expected_columns, f"refresh {refresh}"
uncached = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 4 * MINUTE_MS, query, request_type=RequestType.HEATMAP, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_identical_query_response(from_cache, uncached)
def test_promql_running_the_same_query_twice(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_repeat_{uuid4().hex[:8]}_bucket"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
end_time_ms = start_time_ms + 2 * MINUTE_MS
query = [{"type": "promql", "spec": {"name": "A", "query": f"sum by (le) (increase({metric_name}[2m]))", "step": 60}}]
# the cumulative count of each `le`, one entry per minute. The counters open
# a minute before the query so its first column has something to increase
# over, and start far above their own rise across the range, below which
# increase clips its back-extrapolation at a counter's zero point
le_to_counts = {
"1": [1000, 1005, 1010, 1020],
"2": [2000, 2010, 2025, 2040],
"4": [3000, 3015, 3040, 3070],
"+Inf": [4000, 4022, 4050, 4090],
}
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"__temporality__": "Cumulative", "service": "api", "le": le},
timestamp=start_time + timedelta(minutes=minute),
value=count,
temporality="Cumulative",
type_="Histogram",
)
for le, counts in le_to_counts.items()
for minute, count in enumerate(counts, start=-1)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert first.status_code == HTTPStatus.OK, first.text
second = make_query_request(signoz, token, start_time_ms, end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert second.status_code == HTTPStatus.OK, second.text
# promql reports a column at the instant the range closes, and the second
# run, answered out of what the first one cached, has to keep it
for run, response in (("first", first), ("second", second)):
assert get_heatmap_buckets(response.json(), "A") == [1, 2, 4], run
## what the query returns per `le` is cumulative across `le`, so each
## count is its own minus the one below it, and `le=+Inf` has no finite
## bound to sit on and lands in the trailing slot. increase over a 2m
## window of minutely samples extrapolates one minute's rise to two.
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(response.json(), "A")] == [
(start_time_ms, [10, 10, 10, 14]), # t = 0, the minute brings 5, 10, 15 and 22 arrivals at or below each `le`
(start_time_ms + MINUTE_MS, [10, 20, 20, 6]), # t = 1m, 5, 15, 25 and 28
(end_time_ms, [20, 10, 30, 20]), # t = 2m, 10, 15, 30 and 40
], f"{run} run"
assert_identical_query_response(first, second)
def test_promql_shifting_the_time_range(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"heatmap_cache_shift_{uuid4().hex[:8]}_bucket"
# 40 minutes back clears the flux interval, which holds recent data out of
# the cache. Flooring to a whole minute is what makes the first query aligned
# to its 1m step, and the unaligned one half a step off it
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=40)).timestamp()) // 60 * 60, tz=UTC)
aligned_start_time_ms = int(start_time.timestamp() * 1000)
aligned_end_time_ms = aligned_start_time_ms + 3 * MINUTE_MS
unaligned_start_time_ms = aligned_start_time_ms + MINUTE_MS // 2
unaligned_end_time_ms = aligned_end_time_ms + MINUTE_MS // 2
query = [{"type": "promql", "spec": {"name": "A", "query": f"sum by (le) (max_over_time({metric_name}[2m]))", "step": 60}}]
# a sample every 30s, each `le` counting up by its own fixed amount every
# time. The two queries report 30s apart, so they land on different samples
# and share no count between them
le_to_arrivals_per_sample = {"1": 100, "2": 300, "4": 600, "+Inf": 1000}
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"__temporality__": "Cumulative", "service": "api", "le": le},
timestamp=start_time + timedelta(seconds=30 * half_minute),
value=arrivals_per_sample * (half_minute + 4),
temporality="Cumulative",
type_="Histogram",
)
for le, arrivals_per_sample in le_to_arrivals_per_sample.items()
for half_minute in range(-3, 8)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
aligned_and_cached = make_query_request(signoz, token, aligned_start_time_ms, aligned_end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert aligned_and_cached.status_code == HTTPStatus.OK, aligned_and_cached.text
assert get_heatmap_buckets(aligned_and_cached.json(), "A") == [1, 2, 4]
## each column reads the counters at their latest sample at or before its
## timestamp, and a bucket holds its own `le`'s count less the one below it.
aligned_columns = [
(aligned_start_time_ms, [400, 800, 1200, 1600]), # t = 0, the fourth sample
(aligned_start_time_ms + MINUTE_MS, [600, 1200, 1800, 2400]), # t = 1m, the sixth
(aligned_start_time_ms + 2 * MINUTE_MS, [800, 1600, 2400, 3200]), # t = 2m, the eighth
(aligned_end_time_ms, [1000, 2000, 3000, 4000]), # t = 3m, the tenth
]
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(aligned_and_cached.json(), "A")] == aligned_columns
# an unaligned range is rounded down to the step, so this asks for the same
# instants as the aligned query and gets the same answer
unaligned_and_cached = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, query, request_type=RequestType.HEATMAP, no_cache=False)
assert unaligned_and_cached.status_code == HTTPStatus.OK, unaligned_and_cached.text
assert get_heatmap_buckets(unaligned_and_cached.json(), "A") == [1, 2, 4], "unaligned query, rounded to the step"
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(unaligned_and_cached.json(), "A")] == aligned_columns, "unaligned query, rounded to the step"
assert_identical_query_response(unaligned_and_cached, aligned_and_cached)
# skipping the alignment keeps the range as asked, so every column sits 30s
# off the cached ones, on samples the aligned query never saw. Such a query
# is never cached, so repeating it never gets the aligned answer either
unaligned_query = [{"type": "promql", "spec": {"name": "A", "query": f"sum by (le) (max_over_time({metric_name}[2m]))", "step": 60, "skipStepAlignment": True}}]
unaligned_columns = [
(unaligned_start_time_ms, [500, 1000, 1500, 2000]), # t = 30s, the fifth sample
(unaligned_start_time_ms + MINUTE_MS, [700, 1400, 2100, 2800]), # t = 1m30s, the seventh
(unaligned_start_time_ms + 2 * MINUTE_MS, [900, 1800, 2700, 3600]), # t = 2m30s, the ninth
(unaligned_end_time_ms, [1100, 2200, 3300, 4400]), # t = 3m30s, the eleventh
]
for run in ("first", "second"):
unaligned_as_asked = make_query_request(signoz, token, unaligned_start_time_ms, unaligned_end_time_ms, unaligned_query, request_type=RequestType.HEATMAP, no_cache=False)
assert unaligned_as_asked.status_code == HTTPStatus.OK, unaligned_as_asked.text
assert get_heatmap_buckets(unaligned_as_asked.json(), "A") == [1, 2, 4], f"unaligned query as asked, {run} run"
assert [(column["timestamp"], column["values"]) for column in get_heatmap_columns(unaligned_as_asked.json(), "A")] == unaligned_columns, f"unaligned query as asked, {run} run"

View File

@@ -13,6 +13,7 @@ from fixtures.auth import (
find_user_by_email,
)
from fixtures.role import find_role_by_name, flatten_transaction_groups, transaction_group
from fixtures.serviceaccount import create_service_account, delete_service_account
CRUD_ROLE_NAME = "crud-test-role"
CRUD_ASSIGNEE_ROLE_NAME = "crud-assignee-role"
@@ -301,6 +302,24 @@ def test_delete_role_after_deleting_assigned_user(
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role after deleting its only assignee: {resp.text}"
def test_delete_role_after_deleting_assigned_service_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = create_role(admin_token, "crud-deleted-sa-assignee-role", [transaction_group("read", "metaresource", "dashboard", ["*"])])
service_account_id = create_service_account(signoz, admin_token, "crud-deleted-sa-assignee", role="crud-deleted-sa-assignee-role")
delete_service_account(signoz, admin_token, service_account_id)
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role after deleting its only service account assignee: {resp.text}"
def test_delete_removes_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument

View File

@@ -1,973 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from wiremock.resources.mappings import Mapping
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
)
from fixtures.role import find_role_by_name, transaction_group
from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_RULES_URL = "/api/v2/rules"
V3_RULES_URL = "/api/v3/rules"
RULE_VIEWS_URL = "/api/v2/rule_views"
DOWNTIME_URL = "/api/v1/downtime_schedules"
ROUTE_POLICIES_URL = "/api/v1/route_policies"
_EDITOR_EMAIL = "editor+alertauthz@integration.test"
_EDITOR_PASSWORD = "password123Z$"
_VIEWER_EMAIL = "viewer+alertauthz@integration.test"
_VIEWER_PASSWORD = "password123Z$"
_ACTOR_EMAIL = "customrole+alertauthz@integration.test"
_ACTOR_PASSWORD = "password123Z$"
_ACTOR_ROLE_NAME = "alert-authz-actor"
_TARGET_RULE = "alert authz target"
_VIEW_NAME = "alert-authz-view"
_DOWNTIME_NAME = "alert-authz-downtime"
_ACTOR_DOWNTIME_NAME = "alert-authz-actor-downtime"
_ROUTE_POLICY_NAME = "alert-authz-route-policy"
_ACTOR_ROUTE_POLICY_NAME = "alert-authz-actor-route-policy"
SEED_CHANNEL = {"name": "alert-authz-channel", "email_configs": [{"to": "alert-authz@integration.test"}]}
SEED_CHANNEL_ALT = {"name": "alert-authz-channel-alt", "email_configs": [{"to": "alert-authz-alt@integration.test"}]}
ROUTE_POLICY_TEMPLATE = {"expression": 'threshold.name == "critical"', "channels": [SEED_CHANNEL["name"]], "description": "authz coverage policy"}
RULE_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [SEED_CHANNEL["name"]]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "alert_authz_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
}
RULE_TEMPLATE = {
"description": "authz coverage rule",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": RULE_CONDITION,
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {"groupBy": [], "usePolicy": False, "renotify": {"enabled": False, "interval": "30m", "alertStates": []}},
"version": "v5",
"schemaVersion": "v2alpha1",
}
DOWNTIME_SCHEDULE = {"timezone": "UTC", "startTime": "2026-09-21T00:00:00Z", "endTime": "2026-09-22T00:00:00Z"}
_ACTOR_BASE_TRANSACTION_GROUPS = [
transaction_group("create", "metaresource", "route-policy", ["*"]),
transaction_group("read", "metaresource", "route-policy", ["*"]),
transaction_group("list", "metaresource", "route-policy", ["*"]),
transaction_group("update", "metaresource", "route-policy", ["*"]),
transaction_group("delete", "metaresource", "route-policy", ["*"]),
transaction_group("attach", "metaresource", "route-policy", ["*"]),
transaction_group("detach", "metaresource", "route-policy", ["*"]),
transaction_group("create", "metaresource", "planned-maintenance", ["*"]),
transaction_group("read", "metaresource", "planned-maintenance", ["*"]),
transaction_group("list", "metaresource", "planned-maintenance", ["*"]),
transaction_group("update", "metaresource", "planned-maintenance", ["*"]),
transaction_group("delete", "metaresource", "planned-maintenance", ["*"]),
transaction_group("attach", "metaresource", "planned-maintenance", ["*"]),
transaction_group("detach", "metaresource", "planned-maintenance", ["*"]),
transaction_group("attach", "metaresource", "rule", ["*"]),
]
def test_setup_users_and_channels(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_emails = {user["email"] for user in response.json()["data"]}
for email, role, password, name in (
(_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "alert authz editor"),
(_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "alert authz viewer"),
):
if email not in existing_emails:
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_channel_names = [channel["name"] for channel in response.json()["data"] or []]
for channel in (SEED_CHANNEL, SEED_CHANNEL_ALT):
if channel["name"] not in existing_channel_names:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json=channel,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for policy in response.json()["data"] or []:
if policy["name"] in (_ROUTE_POLICY_NAME, _ACTOR_ROUTE_POLICY_NAME):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for rule in response.json()["data"]["rules"]:
if rule["alert"] == _TARGET_RULE:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(V2_RULES_URL),
json={**RULE_TEMPLATE, "alert": _TARGET_RULE},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
def test_viewer_and_editor_allowed_on_route_policy_reads(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
for email, password in ((_VIEWER_EMAIL, _VIEWER_PASSWORD), (_EDITOR_EMAIL, _EDITOR_PASSWORD)):
token = get_token(email, password)
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"{email}: {response.text}"
policy_id = {policy["name"]: policy["id"] for policy in response.json()["data"]}[_ROUTE_POLICY_NAME]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"{email}: {response.text}"
def test_viewer_and_editor_forbidden_on_route_policy_writes(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
policy_id = {policy["name"]: policy["id"] for policy in response.json()["data"]}[_ROUTE_POLICY_NAME]
for email, password in ((_VIEWER_EMAIL, _VIEWER_PASSWORD), (_EDITOR_EMAIL, _EDITOR_PASSWORD)):
token = get_token(email, password)
for method, path in (
("post", ROUTE_POLICIES_URL),
("put", f"{ROUTE_POLICIES_URL}/{policy_id}"),
("delete", f"{ROUTE_POLICIES_URL}/{policy_id}"),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} {method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
def test_admin_allowed_on_route_policy_writes(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
policy_id = {policy["name"]: policy["id"] for policy in response.json()["data"]}[_ROUTE_POLICY_NAME]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME, "channels": [SEED_CHANNEL_ALT["name"]]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_route_policy_naming_unknown_channel_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME, "channels": ["alert-authz-channel-missing"]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_editor_allowed_on_downtime_mutations(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window updated", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_editor_allowed_on_downtime_naming_rules(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_allowed_on_downtime_reads_and_forbidden_on_writes(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
for path in (DOWNTIME_URL, f"{DOWNTIME_URL}/{downtime_id}"):
response = requests.get(
signoz.self.host_configs["8080"].get(path),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
for method, path in (
("post", DOWNTIME_URL),
("put", f"{DOWNTIME_URL}/{downtime_id}"),
("delete", f"{DOWNTIME_URL}/{downtime_id}"),
):
response = getattr(requests, method)(
signoz.self.host_configs["8080"].get(path),
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_allowed_on_rule_views(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "asc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
):
add_license(signoz, make_http_mocks, get_token)
def test_route_policy_managed_role_tuples_enforced_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"admin create must pass on the migrated route-policy and channel attach tuples: {response.text}"
policy_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"editor read must pass on the migrated route-policy read tuple: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME, "channels": [SEED_CHANNEL_ALT["name"]]},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"editor update must be forbidden, the migration grants editors no route-policy update tuple: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"admin delete must pass on the migrated route-policy delete tuple: {response.text}"
def test_downtime_managed_role_tuples_enforced_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"admin rule list must pass: {response.text}"
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"editor create with a rule must pass on the migrated planned-maintenance create and rule attach tuples: {response.text}"
downtime_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"viewer read must pass on the migrated planned-maintenance read tuple: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"viewer update must be forbidden, the migration grants viewers no planned-maintenance update tuple: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor widen to all rules must pass on the migrated rule attach and detach tuples: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor delete must pass on the migrated planned-maintenance delete tuple: {response.text}"
def test_rule_view_managed_role_tuples_enforced_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"viewer list must pass on the migrated rule list tuple: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"viewer create must pass, rule views are gated on rule list like dashboard views: {response.text}"
view_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"viewer delete must pass on the migrated rule list tuple: {response.text}"
def test_setup_channel_scoped_actor(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_role_ids = {role["name"]: role["id"] for role in response.json()["data"]}
if _ACTOR_ROLE_NAME in existing_role_ids:
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{existing_role_ids[_ACTOR_ROLE_NAME]}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
else:
create_role(admin_token, _ACTOR_ROLE_NAME, _ACTOR_BASE_TRANSACTION_GROUPS)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
if _ACTOR_EMAIL not in {user["email"] for user in response.json()["data"]}:
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="alert authz actor")
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
def test_route_policy_naming_channel_forbidden_without_channel_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ACTOR_ROUTE_POLICY_NAME},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
def test_route_policy_channel_changes_follow_channel_attach_and_detach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS + [transaction_group("attach", "metaresource", "notification-channel", ["*"])]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
json={**ROUTE_POLICY_TEMPLATE, "name": _ACTOR_ROUTE_POLICY_NAME},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
policy_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
json={**ROUTE_POLICY_TEMPLATE, "name": _ACTOR_ROUTE_POLICY_NAME, "channels": [SEED_CHANNEL["name"], SEED_CHANNEL_ALT["name"]]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
json={**ROUTE_POLICY_TEMPLATE, "name": _ACTOR_ROUTE_POLICY_NAME, "channels": [SEED_CHANNEL_ALT["name"]]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={
"description": "",
"transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS + [transaction_group("attach", "metaresource", "notification-channel", ["*"]), transaction_group("detach", "metaresource", "notification-channel", ["*"])],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
json={**ROUTE_POLICY_TEMPLATE, "name": _ACTOR_ROUTE_POLICY_NAME, "channels": [SEED_CHANNEL_ALT["name"]]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_views_forbidden_without_rule_list(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
def test_downtime_naming_rule_forbidden_without_rule_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
for alert_ids in ([target_id], []):
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": alert_ids},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, f"alertIds={alert_ids}: expected 403, got {response.status_code}: {response.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_downtime_rule_removal_forbidden_without_rule_detach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window still with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={
"description": "",
"transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]
+ [
transaction_group("attach", "metaresource", "rule", [target_id]),
transaction_group("detach", "metaresource", "rule", ["*"]),
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
downtime_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_alert_authz_cleanup(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for schedule in response.json()["data"] or []:
if schedule["name"] in (_DOWNTIME_NAME, _ACTOR_DOWNTIME_NAME):
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
json={"name": schedule["name"], "description": schedule["description"], "schedule": schedule["schedule"], "alertIds": []},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for rule in response.json()["data"]["rules"]:
if rule["alert"] == _TARGET_RULE:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for policy in response.json()["data"] or []:
if policy["name"] in (_ROUTE_POLICY_NAME, _ACTOR_ROUTE_POLICY_NAME):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for channel in response.json()["data"] or []:
if channel["name"] in (SEED_CHANNEL["name"], SEED_CHANNEL_ALT["name"]):
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"delete channel: {response.text}"