mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-26 19:11:00 +00:00
Compare commits
5 Commits
test/dashb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e89b49ecf | ||
|
|
8a3702a7f9 | ||
|
|
0376eec24f | ||
|
|
368efd2afe | ||
|
|
fe9165447f |
142
.claude/skills/authz-configurator/SKILL.md
Normal file
142
.claude/skills/authz-configurator/SKILL.md
Normal 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.
|
||||
@@ -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>/`.
|
||||
155
.claude/skills/authz-configurator/references/route-wiring.md
Normal file
155
.claude/skills/authz-configurator/references/route-wiring.md
Normal 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`.
|
||||
@@ -8616,6 +8616,8 @@ components:
|
||||
type: string
|
||||
query:
|
||||
type: string
|
||||
skipStepAlignment:
|
||||
type: boolean
|
||||
stats:
|
||||
type: boolean
|
||||
step:
|
||||
|
||||
@@ -88,10 +88,7 @@ tests/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ ├── routing.ts
|
||||
│ ├── dashboards.ts # dashboard seeding + query_range mocks; panels → panel-editor → panel-config extend it
|
||||
│ ├── panels.ts
|
||||
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/contract
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -131,8 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
@@ -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
385
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6116,6 +6116,10 @@ export interface Querybuildertypesv5PromQueryDTO {
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
skipStepAlignment?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -63,6 +63,7 @@ jest.mock('../../QuickFilters', () => ({
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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] },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -57,10 +57,6 @@
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../../context/PlotContext';
|
||||
import { UPlotChartProps } from '../types';
|
||||
import { prepareAlignedData, setInstanceHandle } from './utils';
|
||||
import { prepareAlignedData } from './utils';
|
||||
|
||||
/**
|
||||
* Check if dimensions have changed
|
||||
@@ -58,7 +58,6 @@ export default function UPlotChart({
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setInstanceHandle(containerRef.current, null);
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
plotRef?.(null);
|
||||
}
|
||||
@@ -102,7 +101,6 @@ export default function UPlotChart({
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
setInstanceHandle(containerRef.current, plot);
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
export function prepareAlignedData({
|
||||
data,
|
||||
@@ -14,33 +14,3 @@ export function prepareAlignedData({
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live uPlot instance, hung off its own container element.
|
||||
*
|
||||
* Charts render to a canvas, so from the outside there is nothing to assert on
|
||||
* beyond pixels — an E2E test can see that a chart exists but not that a series
|
||||
* was hidden, an axis bound applied, or a line turned dashed. Exposing the
|
||||
* instance on its container gives those tests (and anyone debugging in the
|
||||
* console) the real chart state to read.
|
||||
*
|
||||
* On the container rather than a global: it is naturally scoped per panel, it
|
||||
* needs no registry keyed by id, and it is collected along with the node.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ function AddColumnDropdown({
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid={`list-columns-suggestion-${field.name}`}
|
||||
data-testid="list-columns-suggestion"
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -37,12 +37,7 @@ function SortableColumnChip({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.chip}
|
||||
data-testid={`list-column-chip-${name}`}
|
||||
>
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<span {...listeners} className={styles.grip}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -66,7 +61,7 @@ function SortableColumnChip({
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={`Remove ${name}`}
|
||||
testId={`list-column-remove-${name}`}
|
||||
testId="list-column-remove"
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -88,7 +88,6 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
data-testid="preview-pane"
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface ResizableHeaderProps extends Omit<
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
/** Column key, used only to give the drag grip a stable test handle. */
|
||||
columnKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +27,6 @@ export interface ResizableHeaderProps extends Omit<
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
columnKey,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
@@ -37,14 +34,13 @@ function ResizableHeader({
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[columnKey],
|
||||
[],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
@@ -68,7 +64,6 @@ function ResizableHeader({
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
columnKey: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
|
||||
@@ -132,7 +132,6 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
@@ -104,9 +104,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
|
||||
{description && (
|
||||
<Tooltip title={description}>
|
||||
<Info
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId(/^list-columns-suggestion-/);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
74
pkg/sqlmigration/139_delete_orphan_sa_roles.go
Normal file
74
pkg/sqlmigration/139_delete_orphan_sa_roles.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
141
tests/e2e/drivers/dashboard-list.ts
generated
141
tests/e2e/drivers/dashboard-list.ts
generated
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from '../helpers/base-path';
|
||||
|
||||
import {
|
||||
DASHBOARDS_API,
|
||||
removeTrackedDashboards,
|
||||
trackDashboard,
|
||||
} from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The dashboards list page (`/dashboard`): the New dashboard modal's Blank tab
|
||||
// and the per-row actions menu.
|
||||
|
||||
const LIST_PATH = '/dashboard';
|
||||
|
||||
/** A dashboard route, not the list or an editor route. */
|
||||
const DASHBOARD_ROUTE = /\/dashboard\/([^/?#]+)(?:[?#]|$)/;
|
||||
|
||||
export interface NewDashboardFields {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `key:value`, each committed with Enter. */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DashboardList {
|
||||
/** Narrowed by the `query` param to one name, since the list is paged. */
|
||||
open(name?: string): Promise<void>;
|
||||
newDashboardButton(): Locator;
|
||||
nameInput(): Locator;
|
||||
descriptionInput(): Locator;
|
||||
tagsInput(): Locator;
|
||||
tagChips(): Locator;
|
||||
submit(): Locator;
|
||||
/**
|
||||
* Fills and submits the Blank tab, then returns the id from the route it
|
||||
* lands on. The dashboard is deleted when the test ends.
|
||||
*/
|
||||
create(fields: NewDashboardFields): Promise<string>;
|
||||
/** Rows render titles in `dashboard-title-<index>`, not by id. */
|
||||
rowTitle(name: string): Locator;
|
||||
/** Opens the row's actions menu and confirms Delete. */
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
function dashboardListDriver(page: Page): DashboardList {
|
||||
const rowTitle = (name: string): Locator =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) });
|
||||
|
||||
const list: DashboardList = {
|
||||
async open(name) {
|
||||
const search = name
|
||||
? `?${new URLSearchParams({ query: `name CONTAINS '${name}'` }).toString()}`
|
||||
: '';
|
||||
await page.goto(`${LIST_PATH}${search}`);
|
||||
await expect(list.newDashboardButton()).toBeVisible();
|
||||
},
|
||||
newDashboardButton: () => page.getByTestId('new-dashboard-cta'),
|
||||
nameInput: () => page.getByTestId('create-dashboard-name'),
|
||||
descriptionInput: () => page.getByTestId('create-dashboard-description'),
|
||||
tagsInput: () => page.getByTestId('create-dashboard-tags'),
|
||||
tagChips: () => page.getByTestId('create-dashboard-tags-chip'),
|
||||
submit: () => page.getByTestId('create-dashboard-submit'),
|
||||
async create({ name, description, tags = [] }) {
|
||||
await list.newDashboardButton().click();
|
||||
await list.nameInput().fill(name);
|
||||
if (description !== undefined) {
|
||||
await list.descriptionInput().fill(description);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
await list.tagsInput().fill(tag);
|
||||
await list.tagsInput().press('Enter');
|
||||
}
|
||||
await expect(list.tagChips()).toHaveCount(tags.length);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
// The app is served under the base path, and so is its API.
|
||||
new URL(r.url()).pathname === `${BASE_PATH}${DASHBOARDS_API}`,
|
||||
),
|
||||
list.submit().click(),
|
||||
]);
|
||||
// Tracked from the response, before the route settles, so a failed
|
||||
// navigation still cleans up.
|
||||
expect(response.ok(), `POST ${DASHBOARDS_API} failed`).toBe(true);
|
||||
const created = ((await response.json()) as { data: { id: string } }).data
|
||||
.id;
|
||||
trackDashboard(page, created);
|
||||
|
||||
await page.waitForURL(DASHBOARD_ROUTE);
|
||||
const id = DASHBOARD_ROUTE.exec(new URL(page.url()).pathname)?.[1];
|
||||
expect(id).toBe(created);
|
||||
return created;
|
||||
},
|
||||
rowTitle,
|
||||
async delete(name) {
|
||||
const row = rowTitle(name).locator(
|
||||
'xpath=ancestor::div[.//*[@data-testid="dashboard-action-icon"]][1]',
|
||||
);
|
||||
await row.getByTestId('dashboard-action-icon').click();
|
||||
await page.getByTestId('dashboard-action-delete').click();
|
||||
// The confirm is an antd `modal.confirm` with no testid.
|
||||
const confirm = page.locator('.ant-modal-confirm');
|
||||
await expect(confirm).toContainText(name);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
r.url().includes(`${DASHBOARDS_API}/`),
|
||||
),
|
||||
confirm.getByRole('button', { name: 'Delete', exact: true }).click(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const withDashboardList = driver(
|
||||
'dashboardList',
|
||||
({ page }) => dashboardListDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
export const test = withDashboardList(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
355
tests/e2e/drivers/dashboards.ts
generated
355
tests/e2e/drivers/dashboards.ts
generated
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Route,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken } from '../helpers/common';
|
||||
import type { PanelKind, PostableDashboard } from '../helpers/dashboard-spec';
|
||||
import {
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
mockQueryRangeSequence,
|
||||
type MockOptions,
|
||||
type QueryRangeBody,
|
||||
unmockQueryRange,
|
||||
} from '../helpers/query-range-mock';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
|
||||
// `POST /api/v2/dashboards` takes the full perses spec in one call.
|
||||
|
||||
export const DASHBOARDS_API = '/api/v2/dashboards';
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
export const PATH = {
|
||||
dashboard: (id: string): string => `/dashboard/${id}`,
|
||||
/** `panelId` is 'new' for creation. */
|
||||
editor: (id: string, panelId: string): string =>
|
||||
`/dashboard/${id}/panel/${panelId}`,
|
||||
/** Matches the dashboard route itself, with or without a query string. */
|
||||
onDashboard: (id: string): RegExp => new RegExp(`/dashboard/${id}(\\?|$)`),
|
||||
/** Matches the dashboard route once the View modal has written its params. */
|
||||
onDashboardWithParams: (id: string): RegExp =>
|
||||
new RegExp(`/dashboard/${id}\\?`),
|
||||
newPanel: /\/panel\/new\?/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `panel-editor-v2` is not unique: the ResizablePanelGroup derives the same
|
||||
* testid from its `id`, which is also its localStorage layout key.
|
||||
*/
|
||||
export const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
|
||||
|
||||
export interface GettableDashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
locked: boolean;
|
||||
tags: { key: string; value: string }[];
|
||||
spec: PostableDashboard['spec'];
|
||||
}
|
||||
|
||||
export interface Dashboards {
|
||||
/** Created for this test and deleted when it ends. */
|
||||
seed(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndOpen(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndEdit(dashboard: PostableDashboard, panelId: string): Promise<string>;
|
||||
/** Deletes a dashboard the UI created when the test ends. */
|
||||
track(id: string): void;
|
||||
/** The open dashboard's heading. */
|
||||
title(): Locator;
|
||||
/** Waits for the grid, not just the shell. */
|
||||
open(id: string): Promise<void>;
|
||||
/** For a dashboard with no panels: waits for the empty state instead. */
|
||||
openEmpty(id: string): Promise<void>;
|
||||
openEditor(id: string, panelId: string): Promise<void>;
|
||||
/** `kind` is required: without it the route bounces to the dashboard. */
|
||||
openNewPanel(id: string, kind: PanelKind, layoutIndex?: number): Promise<void>;
|
||||
/** Navigates without waiting for anything, for routes expected to redirect. */
|
||||
visit(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
get(id: string): Promise<GettableDashboard>;
|
||||
setLocked(id: string, locked: boolean): Promise<void>;
|
||||
/** A panel's grid geometry as persisted, not as rendered. */
|
||||
gridItem(id: string, panelId: string): Promise<GridGeometry>;
|
||||
/** A query param of the current URL, where the time window lives. */
|
||||
param(name: string): string | null;
|
||||
emptyAddPanel(): Locator;
|
||||
headerAddPanel(): Locator;
|
||||
zoomOut(): Locator;
|
||||
variable(name: string): Locator;
|
||||
confirmDelete(): Locator;
|
||||
/** Free text anywhere on the page, e.g. a confirm dialog's title. */
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
export interface GridGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface QueryRangeMock {
|
||||
/** Fulfils every call with one body. Install before navigating. */
|
||||
respond(body: QueryRangeBody, options?: MockOptions): Promise<void>;
|
||||
fail(
|
||||
message?: string,
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void>;
|
||||
/** One body per call, repeating the last. */
|
||||
sequence(bodies: QueryRangeBody[]): Promise<void>;
|
||||
/** Holds every call until `release` is called, then serves `body`. */
|
||||
hold(body: QueryRangeBody): Promise<{ release: () => void }>;
|
||||
/** Drops interception so later calls reach the backend. */
|
||||
reset(): Promise<void>;
|
||||
/** Resolves with the next query_range request. */
|
||||
nextRequest(): Promise<Request>;
|
||||
/** The paging of the first query in the next query_range request. */
|
||||
nextPaging(): Promise<{ offset?: number; limit?: number }>;
|
||||
/** Counts query_range requests from now on. */
|
||||
countRequests(): { readonly count: number };
|
||||
}
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 400 is almost always the strict decoder rejecting a stray key; the body
|
||||
* names the field, so it is surfaced verbatim.
|
||||
*/
|
||||
async function createViaApi(
|
||||
page: Page,
|
||||
dashboard: PostableDashboard,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(DASHBOARDS_API, {
|
||||
data: dashboard,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableDashboard> {
|
||||
const res = await page.request.get(`${DASHBOARDS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${DASHBOARDS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data;
|
||||
}
|
||||
|
||||
/** Same path, no body: PUT locks, DELETE unlocks. */
|
||||
async function setLockedViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
locked: boolean,
|
||||
): Promise<void> {
|
||||
const url = `${DASHBOARDS_API}/${id}/lock`;
|
||||
const headers = await bearer(page);
|
||||
const res = locked
|
||||
? await page.request.put(url, { headers })
|
||||
: await page.request.delete(url, { headers });
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a dashboard for deletion when the test owning `page` ends. */
|
||||
export function trackDashboard(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not asserted: a test that deleted its own dashboard, or left a closed page
|
||||
* behind, must not fail on cleanup.
|
||||
*/
|
||||
export async function removeTrackedDashboards(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = await bearer(page);
|
||||
for (const id of ids) {
|
||||
await page.request
|
||||
.delete(`${DASHBOARDS_API}/${id}`, { headers })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
/* the dashboards outlive the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardsDriver(page: Page): Dashboards {
|
||||
const editorRoot = (): Locator => page.locator(EDITOR_ROOT);
|
||||
|
||||
const dashboards: Dashboards = {
|
||||
async seed(dashboard) {
|
||||
const id = await createViaApi(page, dashboard);
|
||||
trackDashboard(page, id);
|
||||
return id;
|
||||
},
|
||||
track: (id) => trackDashboard(page, id),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
async seedAndOpen(dashboard) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.open(id);
|
||||
return id;
|
||||
},
|
||||
async seedAndEdit(dashboard, panelId) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.openEditor(id, panelId);
|
||||
return id;
|
||||
},
|
||||
async open(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await page
|
||||
.locator('[data-panel-root]')
|
||||
.first()
|
||||
.waitFor({ state: 'attached' });
|
||||
},
|
||||
async openEmpty(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
},
|
||||
async openEditor(id, panelId) {
|
||||
await page.goto(PATH.editor(id, panelId));
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async openNewPanel(id, kind, layoutIndex = 0) {
|
||||
const search = new URLSearchParams({
|
||||
panelKind: kind,
|
||||
layoutIndex: String(layoutIndex),
|
||||
});
|
||||
await page.goto(`${PATH.editor(id, 'new')}?${search.toString()}`);
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async visit(path) {
|
||||
await page.goto(path);
|
||||
},
|
||||
async reload() {
|
||||
await page.reload();
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
setLocked: (id, locked) => setLockedViaApi(page, id, locked),
|
||||
async gridItem(id, panelId) {
|
||||
const { spec } = await getViaApi(page, id);
|
||||
for (const layout of spec.layouts) {
|
||||
const item = layout.spec.items.find(
|
||||
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
|
||||
);
|
||||
if (item) {
|
||||
return { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
}
|
||||
}
|
||||
throw new Error(`no grid item for panel ${panelId}`);
|
||||
},
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
emptyAddPanel: () => page.getByTestId('add-panel'),
|
||||
headerAddPanel: () => page.getByTestId('add-panel-header'),
|
||||
zoomOut: () => page.getByTestId('zoom-out-btn'),
|
||||
variable: (name) => page.getByTestId(`variable-${name}`),
|
||||
confirmDelete: () => page.getByTestId('confirm-delete'),
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
function queryRangeDriver(page: Page): QueryRangeMock {
|
||||
const isQueryRange = (url: string): boolean => url.includes('/query_range');
|
||||
|
||||
return {
|
||||
respond: (body, options) => mockQueryRange(page, body, options),
|
||||
fail: (message, options) => mockQueryRangeError(page, message, options),
|
||||
sequence: (bodies) => mockQueryRangeSequence(page, bodies),
|
||||
async hold(body) {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
return { release: () => release() };
|
||||
},
|
||||
reset: () => unmockQueryRange(page),
|
||||
nextRequest: () => page.waitForRequest((r) => isQueryRange(r.url())),
|
||||
async nextPaging() {
|
||||
const request = await page.waitForRequest((r) => isQueryRange(r.url()));
|
||||
const body = request.postDataJSON() as {
|
||||
compositeQuery?: {
|
||||
queries?: { spec?: { offset?: number; limit?: number } }[];
|
||||
};
|
||||
} | null;
|
||||
const spec = body?.compositeQuery?.queries?.[0]?.spec;
|
||||
return { offset: spec?.offset, limit: spec?.limit };
|
||||
},
|
||||
countRequests() {
|
||||
const counter = { count: 0 };
|
||||
page.on('request', (request) => {
|
||||
if (isQueryRange(request.url())) {
|
||||
counter.count += 1;
|
||||
}
|
||||
});
|
||||
return counter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const withDashboards = driver(
|
||||
'dashboards',
|
||||
({ page }) => dashboardsDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
const withQueryRange = driver('queryRange', ({ page }) =>
|
||||
queryRangeDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryRange(withDashboards(kit));
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from '../helpers/dashboard-spec';
|
||||
export {
|
||||
flat,
|
||||
type HeatmapPoint,
|
||||
heatmapPoints,
|
||||
type HeatmapSeriesSpec,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
ramp,
|
||||
type SeriesSpec,
|
||||
timestamps,
|
||||
type TimeSeriesPoint,
|
||||
} from '../helpers/query-range-mock';
|
||||
export * from '../testdata/dashboards/panels';
|
||||
13
tests/e2e/drivers/kit.ts
generated
13
tests/e2e/drivers/kit.ts
generated
@@ -27,15 +27,10 @@ export type WorkerLayer<Adds> = <T extends object, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T, W & Adds>;
|
||||
|
||||
/**
|
||||
* A `withX` layer binding `build` to the authenticated page. `dispose` runs
|
||||
* after the test while the page is still open, so it can clean up over the
|
||||
* same session.
|
||||
*/
|
||||
/** A `withX` layer binding `build` to the authenticated page. */
|
||||
export function driver<Name extends string, T>(
|
||||
name: Name,
|
||||
build: (ctx: DriverContext) => T,
|
||||
dispose?: (value: T, ctx: DriverContext) => Promise<void>,
|
||||
): Layer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
// Playwright reads fixture dependencies from the source text, so
|
||||
@@ -44,11 +39,7 @@ export function driver<Name extends string, T>(
|
||||
{ authedPage }: { authedPage: Page },
|
||||
use: (value: T) => Promise<void>,
|
||||
) => {
|
||||
const value = build({ page: authedPage });
|
||||
await use(value);
|
||||
if (dispose) {
|
||||
await dispose(value, { page: authedPage });
|
||||
}
|
||||
await use(build({ page: authedPage }));
|
||||
},
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
|
||||
278
tests/e2e/drivers/panel-config.ts
generated
278
tests/e2e/drivers/panel-config.ts
generated
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type {
|
||||
ContextLink,
|
||||
PanelPluginSpec,
|
||||
Threshold,
|
||||
} from '../helpers/dashboard-spec';
|
||||
import { SINGLE_PANEL_ID } from '../testdata/dashboards/panels';
|
||||
|
||||
import { getViaApi } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The panel editor's per-kind config editors: thresholds, context links,
|
||||
// per-column units and the List columns editor.
|
||||
|
||||
/**
|
||||
* Variant follows panel kind, not a user control: label → TimeSeries/Bar,
|
||||
* comparison → Number, table → Table. Each has its own add button and row
|
||||
* testid prefix.
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
const THRESHOLD_ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
label: 'panel-editor-v2-add-threshold',
|
||||
comparison: 'panel-editor-v2-add-comparison-threshold',
|
||||
table: 'panel-editor-v2-add-table-threshold',
|
||||
};
|
||||
|
||||
const THRESHOLD_ROW_PREFIX: Record<ThresholdVariant, string> = {
|
||||
label: 'threshold',
|
||||
comparison: 'comparison-threshold',
|
||||
table: 'table-threshold',
|
||||
};
|
||||
|
||||
export type ThresholdOperator =
|
||||
| 'above'
|
||||
| 'above_or_equal'
|
||||
| 'below'
|
||||
| 'below_or_equal'
|
||||
| 'equal'
|
||||
| 'not_equal';
|
||||
|
||||
export type ThresholdFormat = 'background' | 'text';
|
||||
|
||||
export interface ThresholdRow {
|
||||
value(): Locator;
|
||||
/** Label variant only. */
|
||||
label(): Locator;
|
||||
edit(): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
remove(): Locator;
|
||||
/** Operator and format are radio tiles keyed by the persisted value. */
|
||||
setOperator(operator: ThresholdOperator): Promise<void>;
|
||||
setFormat(format: ThresholdFormat): Promise<void>;
|
||||
/** Table variant only: the column keys come from the live result. */
|
||||
pickFirstColumn(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
addButton(variant: ThresholdVariant): Locator;
|
||||
/** The section header's quick-add, which also expands a collapsed section. */
|
||||
headerAdd(): Locator;
|
||||
row(variant: ThresholdVariant, index: number): ThresholdRow;
|
||||
}
|
||||
|
||||
export interface ContextLinks {
|
||||
addButton(): Locator;
|
||||
headerAdd(): Locator;
|
||||
dialog(): Locator;
|
||||
label(): Locator;
|
||||
url(): Locator;
|
||||
save(): Locator;
|
||||
cancel(): Locator;
|
||||
addParam(): Locator;
|
||||
paramKey(index: number): Locator;
|
||||
paramValue(index: number): Locator;
|
||||
item(index: number): Locator;
|
||||
edit(index: number): Locator;
|
||||
remove(index: number): Locator;
|
||||
/**
|
||||
* Closes the URL field's suggestion popover, which otherwise keeps the
|
||||
* dialog reflowing so buttons never settle. Escape would dismiss the whole
|
||||
* dialog.
|
||||
*/
|
||||
blurFields(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListColumns {
|
||||
editor(): Locator;
|
||||
chip(name: string): Locator;
|
||||
remove(name: string): Locator;
|
||||
addButton(): Locator;
|
||||
search(): Locator;
|
||||
/** The explicit "Add …" entry, so a typo can't be committed by blurring. */
|
||||
addCustom(): Locator;
|
||||
/** Opens the dropdown and types `term`. */
|
||||
open(term: string): Promise<void>;
|
||||
/**
|
||||
* Clicks the first suggestion once the results for `term` have landed;
|
||||
* returns its field name.
|
||||
*/
|
||||
pickFirstSuggestion(term: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface TableCells {
|
||||
/** The last cell of the first row, where the aggregated value lands. */
|
||||
valueCell(): Locator;
|
||||
/** Inline background, set only while a background threshold matches. */
|
||||
background(cell: Locator): Promise<string>;
|
||||
/** Spans the text format recolours. */
|
||||
coloredText(cell: Locator): Locator;
|
||||
}
|
||||
|
||||
export interface SavedPanel {
|
||||
plugin: PanelPluginSpec;
|
||||
links: ContextLink[];
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
thresholds: Thresholds;
|
||||
contextLinks: ContextLinks;
|
||||
listColumns: ListColumns;
|
||||
columnUnits: {
|
||||
any(): Locator;
|
||||
/** The first per-column unit select, with the column key it targets. */
|
||||
first(): Promise<{ testId: string; key: string }>;
|
||||
};
|
||||
/** The plugin spec and links as persisted, straight from the API. */
|
||||
saved(dashboardId: string, panelId?: string): Promise<SavedPanel>;
|
||||
savedThresholds(dashboardId: string, panelId?: string): Promise<Threshold[]>;
|
||||
numberValue(): Locator;
|
||||
/** Inline colour on the rendered Number value, set only while a threshold matches. */
|
||||
numberValueColor(): Promise<string>;
|
||||
table: TableCells;
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
const COLUMN_UNIT_PREFIX = 'panel-editor-v2-column-unit-';
|
||||
const SUGGESTION_PREFIX = 'list-columns-suggestion-';
|
||||
|
||||
function thresholdRow(
|
||||
page: Page,
|
||||
variant: ThresholdVariant,
|
||||
index: number,
|
||||
): ThresholdRow {
|
||||
const prefix = THRESHOLD_ROW_PREFIX[variant];
|
||||
const byTestId = (part: string): Locator =>
|
||||
page.getByTestId(`${prefix}-${part}-${index}`);
|
||||
return {
|
||||
value: () => byTestId('value'),
|
||||
label: () => byTestId('label'),
|
||||
edit: () => byTestId('edit'),
|
||||
save: () => byTestId('save'),
|
||||
discard: () => byTestId('discard'),
|
||||
remove: () => byTestId('remove'),
|
||||
async setOperator(operator) {
|
||||
await page.getByTestId(`${prefix}-operator-${index}-${operator}`).click();
|
||||
},
|
||||
async setFormat(format) {
|
||||
await page.getByTestId(`${prefix}-format-${index}-${format}`).click();
|
||||
},
|
||||
async pickFirstColumn() {
|
||||
await byTestId('column').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.locator('.ant-select-item-option')
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function panelConfigDriver(page: Page): PanelConfig {
|
||||
const numberValue = (): Locator => page.getByTestId('number-panel-value');
|
||||
const contextLinkLabel = (): Locator => page.getByTestId('context-link-label');
|
||||
const columnUnits = (): Locator =>
|
||||
page.locator(`[data-testid^="${COLUMN_UNIT_PREFIX}"]`);
|
||||
|
||||
const config: PanelConfig = {
|
||||
thresholds: {
|
||||
addButton: (variant) => page.getByTestId(THRESHOLD_ADD_TESTID[variant]),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-threshold-header'),
|
||||
row: (variant, index) => thresholdRow(page, variant, index),
|
||||
},
|
||||
contextLinks: {
|
||||
addButton: () => page.getByTestId('panel-editor-v2-add-link'),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-link-header'),
|
||||
dialog: () => page.getByTestId('context-link-dialog'),
|
||||
label: contextLinkLabel,
|
||||
url: () => page.getByTestId('context-link-url'),
|
||||
save: () => page.getByTestId('context-link-save'),
|
||||
cancel: () => page.getByTestId('context-link-cancel'),
|
||||
addParam: () => page.getByTestId('context-link-add-param'),
|
||||
paramKey: (index) => page.getByTestId(`context-link-param-key-${index}`),
|
||||
paramValue: (index) => page.getByTestId(`context-link-param-value-${index}`),
|
||||
item: (index) => page.getByTestId(`context-link-item-${index}`),
|
||||
edit: (index) => page.getByTestId(`context-link-edit-${index}`),
|
||||
remove: (index) => page.getByTestId(`context-link-remove-${index}`),
|
||||
async blurFields() {
|
||||
await contextLinkLabel().focus();
|
||||
},
|
||||
},
|
||||
listColumns: {
|
||||
editor: () => page.getByTestId('list-columns-editor'),
|
||||
chip: (name) => page.getByTestId(`list-column-chip-${name}`),
|
||||
remove: (name) => page.getByTestId(`list-column-remove-${name}`),
|
||||
addButton: () => page.getByTestId('list-columns-add'),
|
||||
search: () => page.getByTestId('list-columns-search'),
|
||||
addCustom: () => page.getByTestId('list-columns-add-custom'),
|
||||
async open(term) {
|
||||
await page.getByTestId('list-columns-add').click();
|
||||
await page.getByTestId('list-columns-search').fill(term);
|
||||
},
|
||||
async pickFirstSuggestion(term) {
|
||||
const first = page.locator(`[data-testid^="${SUGGESTION_PREFIX}"]`).first();
|
||||
const firstName = async (): Promise<string> =>
|
||||
((await first.getAttribute('data-testid')) ?? '').replace(
|
||||
SUGGESTION_PREFIX,
|
||||
'',
|
||||
);
|
||||
// Search is debounced, so the previous results stay up until this
|
||||
// term's land; and the list repaints as they do, so the row is
|
||||
// clicked by its own testid rather than by position.
|
||||
await expect(page.getByText('Loading…')).toHaveCount(0);
|
||||
await expect.poll(firstName).toContain(term);
|
||||
const name = await firstName();
|
||||
await page.getByTestId(`${SUGGESTION_PREFIX}${name}`).click();
|
||||
return name;
|
||||
},
|
||||
},
|
||||
columnUnits: {
|
||||
any: () => columnUnits().first(),
|
||||
async first() {
|
||||
const testId =
|
||||
(await columnUnits().first().getAttribute('data-testid')) ?? '';
|
||||
return { testId, key: testId.replace(COLUMN_UNIT_PREFIX, '') };
|
||||
},
|
||||
},
|
||||
async saved(dashboardId, panelId = SINGLE_PANEL_ID) {
|
||||
const { spec } = await getViaApi(page, dashboardId);
|
||||
const panel = spec.panels[panelId].spec;
|
||||
return { plugin: panel.plugin.spec, links: panel.links ?? [] };
|
||||
},
|
||||
async savedThresholds(dashboardId, panelId) {
|
||||
return (await config.saved(dashboardId, panelId)).plugin.thresholds ?? [];
|
||||
},
|
||||
numberValue,
|
||||
numberValueColor: () =>
|
||||
numberValue().evaluate((node) => (node as HTMLElement).style.color),
|
||||
table: {
|
||||
valueCell: () =>
|
||||
page
|
||||
.getByTestId('table-panel-renderer')
|
||||
.locator('tbody tr.ant-table-row')
|
||||
.first()
|
||||
.locator('td')
|
||||
.last(),
|
||||
background: (cell) =>
|
||||
cell.evaluate((node) => (node as HTMLElement).style.backgroundColor),
|
||||
coloredText: (cell) => cell.locator('span[style*="color"]'),
|
||||
},
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const withPanelConfig = driver('config', ({ page }) => panelConfigDriver(page));
|
||||
|
||||
export const test = withPanelConfig(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
296
tests/e2e/drivers/panel-editor.ts
generated
296
tests/e2e/drivers/panel-editor.ts
generated
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { EDITOR_ROOT } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { newPanelDriver } from './panel-editor/picker';
|
||||
import { type QueryPane, queryPaneAt } from './panel-editor/query';
|
||||
import { type EditorSelects, selectsAt } from './panel-editor/select';
|
||||
import { type TextPane, textPaneAt } from './panel-editor/text';
|
||||
import {
|
||||
type TypeSwitcher,
|
||||
typeSwitcherAt,
|
||||
} from './panel-editor/type-switcher';
|
||||
import {
|
||||
type Chart,
|
||||
chartAt,
|
||||
type Markdown,
|
||||
markdownAt,
|
||||
RENDERER_TESTID,
|
||||
test as panelsTest,
|
||||
} from './panels';
|
||||
|
||||
// The panel editor; config sections only mount their editors while open.
|
||||
// Each pane lives in its own part under ./panel-editor.
|
||||
|
||||
export const EditorText = {
|
||||
title: 'Configure panel',
|
||||
unsavedBadge: 'Unsaved Changes',
|
||||
save: 'Save changes',
|
||||
switchToView: 'Switch to View Mode',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your unsaved edits to this panel will be lost.',
|
||||
savedToast: 'Panel saved',
|
||||
lockedReason: 'This dashboard is locked',
|
||||
runQuery: 'Run Query',
|
||||
} as const;
|
||||
|
||||
export const QueryTab = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse Query',
|
||||
promql: 'PromQL',
|
||||
ai: 'AI Query Builder',
|
||||
} as const;
|
||||
|
||||
/** SettingsSection titles as rendered. */
|
||||
export const Section = {
|
||||
visualization: 'Visualization',
|
||||
formatting: 'Formatting & Units',
|
||||
axes: 'Axes',
|
||||
legend: 'Legend',
|
||||
chartAppearance: 'Chart Appearance',
|
||||
buckets: 'Buckets',
|
||||
thresholds: 'Thresholds',
|
||||
contextLinks: 'Context Links',
|
||||
/** Text only. */
|
||||
panelAppearance: 'Panel appearance',
|
||||
} as const;
|
||||
|
||||
/** Config controls, each testid'd as `panel-editor-v2-<name>`. */
|
||||
export type EditorControl =
|
||||
| 'soft-min'
|
||||
| 'soft-max'
|
||||
| 'y-scale'
|
||||
| 'log-scale'
|
||||
| 'unit'
|
||||
| 'decimals'
|
||||
| 'time-preference'
|
||||
| 'stacked-bar-chart'
|
||||
| 'stack-mode'
|
||||
| 'fill-spans'
|
||||
| 'fill-mode'
|
||||
| 'fill-opacity'
|
||||
| 'span-gaps'
|
||||
| 'span-gaps-value'
|
||||
| 'show-points'
|
||||
| 'line-style'
|
||||
| 'line-interpolation'
|
||||
| 'legend-position'
|
||||
| 'legend-colors'
|
||||
| 'legend-search'
|
||||
| 'bucket-count'
|
||||
| 'bucket-width'
|
||||
| 'merge-queries'
|
||||
| 'heatmap-color-mode'
|
||||
| 'heatmap-palette'
|
||||
| 'heatmap-color-scale'
|
||||
| 'heatmap-color-steps'
|
||||
| 'heatmap-min-count'
|
||||
| 'heatmap-max-count'
|
||||
| 'heatmap-fill'
|
||||
| 'heatmap-preview'
|
||||
| 'type-revert';
|
||||
|
||||
export interface PatchOperation {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PanelEditor extends EditorSelects, TypeSwitcher {
|
||||
root(): Locator;
|
||||
title(): Locator;
|
||||
description(): Locator;
|
||||
saveButton(): Locator;
|
||||
closeButton(): Locator;
|
||||
unsavedBadge(): Locator;
|
||||
switchToView(): Locator;
|
||||
/** Save is not gated on dirty state; fails the test on a rejected PATCH. */
|
||||
save(): Promise<void>;
|
||||
/** Clicks Save and returns whether the PATCH was accepted, for rejection paths. */
|
||||
trySave(): Promise<{ ok: boolean; body: string }>;
|
||||
/**
|
||||
* A dirty panel raises the discard dialog; `expectDirty` answers it, with
|
||||
* `keepEditing` choosing Cancel over Discard.
|
||||
*/
|
||||
close(options?: {
|
||||
expectDirty?: boolean;
|
||||
keepEditing?: boolean;
|
||||
}): Promise<void>;
|
||||
discardModal(): Locator;
|
||||
/** Records the RFC-6902 ops each save sends. */
|
||||
capturePatches(): PatchOperation[][];
|
||||
section: {
|
||||
toggle(title: string): Locator;
|
||||
/** Idempotent: a blind click on an open section collapses it. */
|
||||
expand(title: string): Promise<void>;
|
||||
collapse(title: string): Promise<void>;
|
||||
};
|
||||
control(name: EditorControl): Locator;
|
||||
/**
|
||||
* A ConfigTiles choice. The radio input is visually hidden, so the label
|
||||
* carrying `<group testid>-<value>` is the click target.
|
||||
*/
|
||||
tile(name: EditorControl, value: string): Locator;
|
||||
setTile(name: EditorControl, value: string): Promise<void>;
|
||||
/** The tile's radio input, for `toBeChecked`. */
|
||||
tileInput(name: EditorControl, value: string): Locator;
|
||||
/**
|
||||
* Whether a refresh or tab close would be held by the editor's
|
||||
* `beforeunload` prompt, checked without leaving the page.
|
||||
*/
|
||||
blocksUnload(): Promise<boolean>;
|
||||
/**
|
||||
* Drives a slider control from the keyboard ('Home', 'End', 'ArrowRight', …);
|
||||
* a key commits like a released drag.
|
||||
*/
|
||||
pressSlider(name: EditorControl, key: string): Promise<void>;
|
||||
text: TextPane;
|
||||
/** The heading text, which carries no testid. */
|
||||
heading(): Locator;
|
||||
/** A disabled Save swallows pointer events, so this hovers its tooltip trigger. */
|
||||
hoverSave(): Promise<void>;
|
||||
query: QueryPane;
|
||||
preview: {
|
||||
root(): Locator;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
chart: Chart;
|
||||
markdown: Markdown;
|
||||
};
|
||||
}
|
||||
|
||||
function isDashboardPatch(method: string, url: string): boolean {
|
||||
return method === 'PATCH' && /\/api\/v2\/dashboards\//.test(url);
|
||||
}
|
||||
|
||||
function editorDriver(page: Page): PanelEditor {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
page.getByTestId(`panel-editor-v2-${suffix}`);
|
||||
const preview = (): Locator => page.getByTestId('preview-pane');
|
||||
const sectionToggle = (title: string): Locator =>
|
||||
page.getByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
);
|
||||
|
||||
const editor: PanelEditor = {
|
||||
root: () => page.locator(EDITOR_ROOT),
|
||||
title: () => byTestId('title'),
|
||||
description: () => byTestId('description'),
|
||||
saveButton: () => byTestId('save'),
|
||||
closeButton: () => byTestId('close'),
|
||||
unsavedBadge: () => byTestId('unsaved-badge'),
|
||||
switchToView: () => byTestId('switch-to-view'),
|
||||
async save() {
|
||||
const { ok, body } = await editor.trySave();
|
||||
// A rejected patch leaves the editor open, which would otherwise surface
|
||||
// as an unrelated timeout several lines later.
|
||||
expect(ok, `dashboard PATCH failed: ${body}`).toBe(true);
|
||||
},
|
||||
async trySave() {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isDashboardPatch(r.request().method(), r.url()),
|
||||
),
|
||||
byTestId('save').click(),
|
||||
]);
|
||||
return { ok: response.ok(), body: await response.text() };
|
||||
},
|
||||
async close(options) {
|
||||
await byTestId('close').click();
|
||||
if (!options?.expectDirty) {
|
||||
return;
|
||||
}
|
||||
await expect(byTestId('discard-modal')).toBeVisible();
|
||||
await byTestId(
|
||||
options.keepEditing ? 'discard-cancel' : 'discard-confirm',
|
||||
).click();
|
||||
},
|
||||
discardModal: () => byTestId('discard-modal'),
|
||||
capturePatches() {
|
||||
const batches: PatchOperation[][] = [];
|
||||
page.on('request', (request) => {
|
||||
if (!isDashboardPatch(request.method(), request.url())) {
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as PatchOperation[] | null;
|
||||
if (body) {
|
||||
batches.push(body);
|
||||
}
|
||||
});
|
||||
return batches;
|
||||
},
|
||||
section: {
|
||||
toggle: sectionToggle,
|
||||
async expand(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
},
|
||||
async collapse(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
},
|
||||
},
|
||||
control: (name) => byTestId(name),
|
||||
tile: (name, value) => byTestId(`${name}-${value}`),
|
||||
async setTile(name, value) {
|
||||
await editor.tile(name, value).click();
|
||||
},
|
||||
tileInput: (name, value) => editor.tile(name, value).locator('input'),
|
||||
blocksUnload: () =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}),
|
||||
async pressSlider(name, key) {
|
||||
const thumb = byTestId(name).getByRole('slider');
|
||||
await thumb.focus();
|
||||
await thumb.press(key);
|
||||
},
|
||||
text: textPaneAt(page),
|
||||
...selectsAt(page),
|
||||
heading: () => editor.root().getByText(EditorText.title),
|
||||
async hoverSave() {
|
||||
await page
|
||||
.locator('[data-slot="tooltip-trigger"]')
|
||||
.filter({ has: byTestId('save') })
|
||||
.hover();
|
||||
},
|
||||
...typeSwitcherAt(page),
|
||||
query: queryPaneAt(page, EditorText.runQuery),
|
||||
preview: {
|
||||
root: preview,
|
||||
renderer: (kind) => preview().getByTestId(RENDERER_TESTID[kind]),
|
||||
chart: chartAt(page, () => preview().getByTestId('uplot-main-div')),
|
||||
markdown: markdownAt(() =>
|
||||
preview().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
const withEditor = driver('editor', ({ page }) => editorDriver(page));
|
||||
const withNewPanel = driver('newPanel', ({ page }) => newPanelDriver(page));
|
||||
|
||||
export const test = withNewPanel(withEditor(panelsTest));
|
||||
|
||||
export * from './panels';
|
||||
export * from './panel-editor/picker';
|
||||
export * from './panel-editor/query';
|
||||
export * from './panel-editor/select';
|
||||
export * from './panel-editor/text';
|
||||
export * from './panel-editor/type-switcher';
|
||||
131
tests/e2e/drivers/panel-editor/picker.ts
generated
131
tests/e2e/drivers/panel-editor/picker.ts
generated
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
/** The type browser's catalog, in render order. */
|
||||
export const PANEL_TYPE_TILES: {
|
||||
kind: PanelKind;
|
||||
label: string;
|
||||
category: string;
|
||||
}[] = [
|
||||
{
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
category: 'Distributions',
|
||||
},
|
||||
{ kind: PanelKind.Heatmap, label: 'Heatmap', category: 'Distributions' },
|
||||
{ kind: PanelKind.Number, label: 'Number', category: 'Single values' },
|
||||
{ kind: PanelKind.Table, label: 'Table', category: 'Raw records' },
|
||||
{ kind: PanelKind.List, label: 'List', category: 'Raw records' },
|
||||
{ kind: PanelKind.Text, label: 'Text', category: 'Documentation' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The New panel drawer every Add panel trigger opens. A tile click only selects
|
||||
* (TimeSeries is preselected); the confirm button navigates to the editor.
|
||||
*/
|
||||
export interface NewPanelPicker {
|
||||
root(): Locator;
|
||||
tile(kind: PanelKind): Locator;
|
||||
/** Visible tiles, in render order. */
|
||||
tiles(): Locator;
|
||||
/** Visible group headings, in render order. */
|
||||
groups(): Locator;
|
||||
search(): Locator;
|
||||
/** A category chip; its accessible name carries the count. */
|
||||
category(label: string): Locator;
|
||||
emptyState(): Locator;
|
||||
/** Reads "Add panel", or "Add to <section>" once there is a choice. */
|
||||
confirmButton(): Locator;
|
||||
/** Selects without confirming. */
|
||||
pick(kind: PanelKind): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
/** Selects and confirms, landing on the editor route. */
|
||||
choose(kind: PanelKind): Promise<void>;
|
||||
/** `value` is the layoutIndex, or 'root' for a root yet to be created. */
|
||||
chooseSection(value: string): Promise<void>;
|
||||
/** Switches the target to a new section named `title`. */
|
||||
newSection(title: string): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
|
||||
sectionAddPanel(): Locator;
|
||||
}
|
||||
|
||||
export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
const root = (): Locator => page.getByTestId(NEW_PANEL_DRAWER);
|
||||
const tiles = (): Locator =>
|
||||
root().locator('[data-testid^="panel-type-signoz/"]');
|
||||
|
||||
const picker: NewPanelPicker = {
|
||||
root,
|
||||
tile: (kind) => root().getByTestId(`panel-type-${kind}`),
|
||||
tiles,
|
||||
groups: () => root().locator('section > span:first-child'),
|
||||
search: () => root().getByTestId('panel-type-search'),
|
||||
category: (label) =>
|
||||
root()
|
||||
.locator('button[aria-pressed]:not([data-testid])')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(label)}\\d+$`) }),
|
||||
emptyState: () => root().getByText(/No panel types match/),
|
||||
confirmButton: () => root().getByTestId('panel-type-confirm'),
|
||||
async pick(kind) {
|
||||
await picker.tile(kind).click();
|
||||
await expect(picker.tile(kind)).toHaveAttribute('aria-pressed', 'true');
|
||||
},
|
||||
async confirm() {
|
||||
await picker.confirmButton().click();
|
||||
await page.waitForURL(PATH.newPanel);
|
||||
},
|
||||
async choose(kind) {
|
||||
await picker.pick(kind);
|
||||
await picker.confirm();
|
||||
},
|
||||
async chooseSection(value) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId(`panel-section-option-${value}`).click();
|
||||
},
|
||||
async newSection(title) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId('panel-section-create').click();
|
||||
await root().getByTestId('panel-section-name').fill(title);
|
||||
},
|
||||
async cancel() {
|
||||
await root().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
},
|
||||
sectionAddPanel: () =>
|
||||
page.locator('[data-testid^="section-add-panel-"]').first(),
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
63
tests/e2e/drivers/panel-editor/query.ts
generated
63
tests/e2e/drivers/panel-editor/query.ts
generated
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface QueryPane {
|
||||
builder(): Locator;
|
||||
tab(label: string): Locator;
|
||||
/** Has no testid at its call site. */
|
||||
runButton(): Locator;
|
||||
/** Clicks Run and waits for the response. */
|
||||
run(): Promise<void>;
|
||||
/** Ctrl/Cmd+Enter from inside the builder; waits for the response. */
|
||||
runShortcut(): Promise<void>;
|
||||
/** Picks a metric in the aggregation at `index`; options load as you type. */
|
||||
selectMetric(metricName: string, index?: number): Promise<void>;
|
||||
metricInput(index?: number): Locator;
|
||||
}
|
||||
|
||||
export function queryPaneAt(page: Page, runLabel: string): QueryPane {
|
||||
const builder = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-query-builder');
|
||||
const pane: QueryPane = {
|
||||
builder: () => builder(),
|
||||
// exact: 'Query Builder' is also a prefix of the 'AI Query Builder' tab.
|
||||
tab: (label) => builder().getByRole('tab', { name: label, exact: true }),
|
||||
runButton: () => page.getByRole('button', { name: runLabel }),
|
||||
async run() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
pane.runButton().click(),
|
||||
]);
|
||||
},
|
||||
async runShortcut() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
(async () => {
|
||||
await builder().click();
|
||||
await page.keyboard.press('ControlOrMeta+Enter');
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async selectMetric(metricName, index = 0) {
|
||||
// An antd AutoComplete: the testid is on a wrapper.
|
||||
const field = page.getByTestId(`metric-name-selector-${index}`);
|
||||
await field.click();
|
||||
await field.locator('input').fill(metricName);
|
||||
const dropdown = page.locator(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
);
|
||||
await expect(dropdown).toBeVisible();
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
metricInput: (index = 0) =>
|
||||
page.getByTestId(`metric-name-selector-${index}`).locator('input'),
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
76
tests/e2e/drivers/panel-editor/select.ts
generated
76
tests/e2e/drivers/panel-editor/select.ts
generated
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// antd popups portal to `document.body`, so a testid finds the trigger and the
|
||||
// options live in a detached `.ant-select-dropdown`.
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface EditorSelects {
|
||||
/** Picks an antd Select option by the trigger's testid. */
|
||||
select(triggerTestId: string, optionLabel: string): Promise<void>;
|
||||
/** Types first: long option lists are virtualised. */
|
||||
searchSelect(
|
||||
triggerTestId: string,
|
||||
searchTerm: string,
|
||||
optionLabel: string,
|
||||
): Promise<void>;
|
||||
selectOptions(triggerTestId: string): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
async function openDropdown(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
): Promise<Locator> {
|
||||
const trigger = page.getByTestId(triggerTestId);
|
||||
await trigger.click();
|
||||
// Resolve this Select's own list: a closing dropdown still matches
|
||||
// `:not(.ant-select-dropdown-hidden)`, so two in a row trip strict mode.
|
||||
const listId = await trigger.locator('input').getAttribute('aria-controls');
|
||||
const dropdown = listId
|
||||
? page
|
||||
.locator(`#${listId}`)
|
||||
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
|
||||
: page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.last();
|
||||
await expect(dropdown).toBeVisible();
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
export function selectsAt(page: Page): EditorSelects {
|
||||
return {
|
||||
async select(triggerTestId, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async searchSelect(triggerTestId, searchTerm, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async selectOptions(triggerTestId) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label: node.textContent?.trim() ?? '',
|
||||
disabled: node.classList.contains('ant-select-item-option-disabled'),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
50
tests/e2e/drivers/panel-editor/text.ts
generated
50
tests/e2e/drivers/panel-editor/text.ts
generated
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/** The Text kind's editor pane and its own config controls. */
|
||||
export interface TextPane {
|
||||
pane(): Locator;
|
||||
/** The CodeMirror document. */
|
||||
input(): Locator;
|
||||
/**
|
||||
* Replaces the whole body. Inserted as text, not typed, so CodeMirror's
|
||||
* bracket auto-close cannot double `[` or `(`.
|
||||
*/
|
||||
setMarkdown(markdown: string): Promise<void>;
|
||||
toolbar(): Locator;
|
||||
/** Rendered only when the dashboard has variables. */
|
||||
insertVariableButton(): Locator;
|
||||
insertVariable(name: string): Promise<void>;
|
||||
/** The "Hide panel header" switch, inline under the title, not in a section. */
|
||||
hideHeader(): Locator;
|
||||
/** `vertical-horizontal`, e.g. 'center-center'. */
|
||||
alignment(vertical: string, horizontal: string): Locator;
|
||||
}
|
||||
|
||||
export function textPaneAt(page: Page): TextPane {
|
||||
const textPane = (): Locator => page.getByTestId('text-panel-editor-pane');
|
||||
const textInput = (): Locator => textPane().locator('.cm-content');
|
||||
return {
|
||||
pane: textPane,
|
||||
input: textInput,
|
||||
async setMarkdown(markdown) {
|
||||
await textInput().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.insertText(markdown);
|
||||
},
|
||||
toolbar: () => textPane().getByTestId('markdown-editor-toolbar'),
|
||||
insertVariableButton: () =>
|
||||
textPane().getByTestId('markdown-insert-variable'),
|
||||
async insertVariable(name) {
|
||||
await textPane().getByTestId('markdown-insert-variable').click();
|
||||
await page.getByTestId(`markdown-variable-${name}`).click();
|
||||
},
|
||||
hideHeader: () => page.getByTestId('panel-header-hide'),
|
||||
alignment: (vertical, horizontal) =>
|
||||
page.getByTestId(`text-layout-align-${vertical}-${horizontal}`),
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
|
||||
import { type SelectOption } from './select';
|
||||
|
||||
const TYPE_SWITCHER_DRAWER = 'panel-type-switcher-drawer';
|
||||
|
||||
export interface TypeSwitcher {
|
||||
typeSwitcher(): Locator;
|
||||
/** Opens the kind picker drawer and picks one. */
|
||||
switchType(kind: PanelKind): Promise<void>;
|
||||
/** A tile in the open type-switcher drawer. */
|
||||
typeOption(kind: PanelKind): Locator;
|
||||
openTypeSwitcher(): Promise<Locator>;
|
||||
/** Every tile in the type switcher, opening it; `disabled` reads aria-disabled. */
|
||||
typeOptions(): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles are aria-disabled, not disabled, so their reason tooltip still hovers.
|
||||
* The label is the display name alone: the tile also renders a preview and a
|
||||
* description.
|
||||
*/
|
||||
async function tileOptions(scope: Locator): Promise<SelectOption[]> {
|
||||
const tiles = scope.locator('[data-testid^="panel-type-signoz/"]');
|
||||
await expect(tiles.first()).toBeVisible();
|
||||
return tiles.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label:
|
||||
node
|
||||
.querySelector(
|
||||
':scope > span:last-child > span:first-child > span:first-child',
|
||||
)
|
||||
?.textContent?.trim() ?? '',
|
||||
disabled: node.getAttribute('aria-disabled') === 'true',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function typeSwitcherAt(page: Page): TypeSwitcher {
|
||||
const trigger = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-type-switcher');
|
||||
const switcher: TypeSwitcher = {
|
||||
typeSwitcher: trigger,
|
||||
typeOption: (kind) =>
|
||||
page.getByTestId(TYPE_SWITCHER_DRAWER).getByTestId(`panel-type-${kind}`),
|
||||
async openTypeSwitcher() {
|
||||
await trigger().click();
|
||||
const drawer = page.getByTestId(TYPE_SWITCHER_DRAWER);
|
||||
await expect(drawer).toBeVisible();
|
||||
return drawer;
|
||||
},
|
||||
async switchType(kind) {
|
||||
await switcher.openTypeSwitcher();
|
||||
await switcher.typeOption(kind).click();
|
||||
await expect(page.getByTestId(TYPE_SWITCHER_DRAWER)).toHaveCount(0);
|
||||
},
|
||||
async typeOptions() {
|
||||
const drawer = await switcher.openTypeSwitcher();
|
||||
return tileOptions(drawer);
|
||||
},
|
||||
};
|
||||
return switcher;
|
||||
}
|
||||
568
tests/e2e/drivers/panel-kinds.ts
generated
568
tests/e2e/drivers/panel-kinds.ts
generated
@@ -1,568 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildDashboard,
|
||||
type EditorControl,
|
||||
GOLDEN,
|
||||
heatmapPoints,
|
||||
heatmapTooltipAt,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
NEW_KINDS_MARKDOWN,
|
||||
type Panel,
|
||||
panel,
|
||||
type PanelHandle,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
type QueryRangeMock,
|
||||
QueryTab,
|
||||
ramp,
|
||||
rawQuery,
|
||||
RENDERER_TESTID,
|
||||
Section,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
type ThresholdVariant,
|
||||
} from './panel-config';
|
||||
|
||||
// One row per panel kind, every value read off the kind's definition.ts and
|
||||
// sections.ts (and capabilities.ts for the query matrix). The contract specs
|
||||
// generate one test per row, so a new kind is one row here.
|
||||
|
||||
export type QueryType = 'builder' | 'clickhouse' | 'promql';
|
||||
|
||||
export type DownloadFormat = 'CSV' | 'PNG' | 'SVG';
|
||||
|
||||
/**
|
||||
* Every collapsible section title. Text's PanelHeader section is left out: it
|
||||
* renders inline in Panel Details, with no toggle of its own.
|
||||
*/
|
||||
export const ALL_SECTIONS: string[] = Object.values(Section);
|
||||
|
||||
/** The axis controls a kind can declare; Heatmap has `y-scale` only. */
|
||||
export const AXIS_CONTROLS: EditorControl[] = [
|
||||
'soft-min',
|
||||
'soft-max',
|
||||
'log-scale',
|
||||
'y-scale',
|
||||
];
|
||||
|
||||
export interface PanelKindCase {
|
||||
kind: PanelKind;
|
||||
/** `displayName`: the picker tile, switcher option and tooltip wording. */
|
||||
label: string;
|
||||
renderer: string;
|
||||
/** Renders from its own spec: no query, so no data states. */
|
||||
queryless: boolean;
|
||||
/** The panel as seeded; renders against golden data. */
|
||||
panel(): Panel;
|
||||
/** A one-panel dashboard holding `panel()` under SINGLE_PANEL_ID. */
|
||||
seed(): PostableDashboard;
|
||||
/** A mocked result the kind renders; null when queryless. */
|
||||
body(): QueryRangeBody | null;
|
||||
/** A mocked successful result with nothing in it; null when queryless. */
|
||||
emptyBody(): QueryRangeBody | null;
|
||||
/** Number renders the shared empty state under its own testid. */
|
||||
noDataTestId: string;
|
||||
sections: string[];
|
||||
forbiddenSections: string[];
|
||||
/** Visualization declares the time-preference control. */
|
||||
timePreference: boolean;
|
||||
/** Axis controls shown once Axes opens; the rest of AXIS_CONTROLS are absent. */
|
||||
axisControls: EditorControl[];
|
||||
queryTabs: string[];
|
||||
/** Query types the switcher accepts, AI excluded. */
|
||||
queryTypes: QueryType[];
|
||||
/** Builder signals the switcher accepts. */
|
||||
signals: Signal[];
|
||||
downloads: DownloadFormat[];
|
||||
createAlert: boolean;
|
||||
drilldown: boolean;
|
||||
/** The kind's data gesture: what a drilldown would hang off. */
|
||||
click(target: PanelHandle): Promise<void>;
|
||||
headerSearch: boolean;
|
||||
thresholdVariant: ThresholdVariant | null;
|
||||
columnsEditor: boolean;
|
||||
}
|
||||
|
||||
const BUCKETS = [10, 50, 100, 500];
|
||||
|
||||
const ALL_SIGNALS: Signal[] = ['metrics', 'logs', 'traces'];
|
||||
const ALL_TABS = [
|
||||
QueryTab.builder,
|
||||
QueryTab.clickhouse,
|
||||
QueryTab.promql,
|
||||
QueryTab.ai,
|
||||
];
|
||||
const IMAGES: DownloadFormat[] = ['PNG', 'SVG'];
|
||||
|
||||
const CHART_SECTIONS = [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
];
|
||||
|
||||
function oneSeries(): QueryRangeBody {
|
||||
return QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
}
|
||||
|
||||
function serviceScalar(rows: (string | number)[][]): QueryRangeBody {
|
||||
return QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
function singlePanel(content: Panel): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [{ title: 'Section', panels: { [SINGLE_PANEL_ID]: content } }],
|
||||
});
|
||||
}
|
||||
|
||||
type RowInput = Omit<
|
||||
PanelKindCase,
|
||||
'renderer' | 'seed' | 'forbiddenSections' | 'noDataTestId'
|
||||
> &
|
||||
Partial<Pick<PanelKindCase, 'noDataTestId'>>;
|
||||
|
||||
function row(input: RowInput): PanelKindCase {
|
||||
return {
|
||||
noDataTestId: 'panel-no-data',
|
||||
...input,
|
||||
renderer: RENDERER_TESTID[input.kind],
|
||||
seed: () => singlePanel(input.panel()),
|
||||
forbiddenSections: ALL_SECTIONS.filter(
|
||||
(title) => !input.sections.includes(title),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const clickChartPoint = (target: PanelHandle): Promise<void> =>
|
||||
target.chart.clickPoint();
|
||||
const clickChart = (target: PanelHandle): Promise<void> => target.chart.click();
|
||||
|
||||
/**
|
||||
* A slice's bounding-box centre can fall in the donut hole, so this clicks the
|
||||
* centroid, where the slice's label leader line starts.
|
||||
*/
|
||||
async function clickPieSlice(target: PanelHandle): Promise<void> {
|
||||
const leader = target
|
||||
.renderer(PanelKind.PieChart)
|
||||
.locator('svg g line')
|
||||
.first();
|
||||
const { x, y } = await leader.evaluate((node) => {
|
||||
const line = node as SVGLineElement;
|
||||
const matrix = line.getScreenCTM();
|
||||
const point = new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix ?? undefined);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
await target.root().page().mouse.click(x, y);
|
||||
}
|
||||
|
||||
/** Waits for the cell tooltip, so a missing menu is not a click that missed. */
|
||||
async function clickHeatmapCell(target: PanelHandle): Promise<void> {
|
||||
await target.chart.click();
|
||||
await expect(heatmapTooltipAt(target.root().page()).root()).toBeVisible();
|
||||
}
|
||||
|
||||
export const PANEL_KINDS: PanelKindCase[] = [
|
||||
row({
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.TimeSeries, { name: 'Calls by service' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.BarChart, { name: 'Calls bar' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: CHART_SECTIONS,
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.AreaChart, { name: 'Calls area' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({ metricName: GOLDEN.metrics.latencySum, groupBy: [] }),
|
||||
}),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.legend,
|
||||
Section.buckets,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: false,
|
||||
click: clickChart,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Number,
|
||||
label: 'Number',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
body: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [[1234]] }),
|
||||
emptyBody: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [] }),
|
||||
noDataTestId: 'number-panel-no-data',
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
// The whole renderer is the click target.
|
||||
click: (target) => target.renderer(PanelKind.Number).click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'comparison',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.PieChart, { name: 'Calls share' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.legend,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: clickPieSlice,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Table,
|
||||
label: 'Table',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.Table, { name: 'Calls table' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: ['CSV', ...IMAGES],
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: (target) => target.table.rows().first().locator('td').last().click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: 'table',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.List,
|
||||
label: 'List',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
body: () =>
|
||||
QueryRange.raw(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `log line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
})),
|
||||
),
|
||||
emptyBody: () => QueryRange.raw([]),
|
||||
sections: [Section.visualization],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.ai],
|
||||
queryTypes: ['builder'],
|
||||
signals: ['logs', 'traces'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) =>
|
||||
target.renderer(PanelKind.List).getByText('log line 0').click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: true,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Heatmap,
|
||||
label: 'Heatmap',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Heatmap, { name: 'Calls heatmap', query: heatmapQuery() }),
|
||||
body: () =>
|
||||
QueryRange.heatmap(BUCKETS, [
|
||||
{
|
||||
labels: { 'service.name': 'adservice' },
|
||||
points: heatmapPoints(12, BUCKETS.length + 1),
|
||||
},
|
||||
]),
|
||||
// Bucket bounds but no series: the heatmap's own empty body.
|
||||
emptyBody: () => QueryRange.heatmap(BUCKETS, []),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.chartAppearance,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: ['y-scale'],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.promql],
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ['metrics'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: clickHeatmapCell,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Text,
|
||||
label: 'Text',
|
||||
queryless: true,
|
||||
panel: () =>
|
||||
panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
body: () => null,
|
||||
emptyBody: () => null,
|
||||
sections: [Section.visualization, Section.panelAppearance],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [],
|
||||
queryTypes: [],
|
||||
signals: [],
|
||||
downloads: [],
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) => target.markdown.root().click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export interface NoDataState {
|
||||
root(): Locator;
|
||||
action(): Locator;
|
||||
secondaryAction(): Locator;
|
||||
}
|
||||
|
||||
/** The kind's empty state; its actions take the state's testid as a prefix. */
|
||||
export function noDataOf(c: PanelKindCase, target: PanelHandle): NoDataState {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
target.root().getByTestId(`${c.noDataTestId}${suffix}`);
|
||||
return {
|
||||
root: () => byTestId(''),
|
||||
action: () => byTestId('-action'),
|
||||
secondaryAction: () => byTestId('-secondary-action'),
|
||||
};
|
||||
}
|
||||
|
||||
export const QUERYABLE_KINDS = PANEL_KINDS.filter((c) => !c.queryless);
|
||||
|
||||
export function kindCase(kind: PanelKind): PanelKindCase {
|
||||
const match = PANEL_KINDS.find((c) => c.kind === kind);
|
||||
if (!match) {
|
||||
throw new Error(`no PANEL_KINDS row for ${kind}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's panel under SINGLE_PANEL_ID, below a tall queryless filler so it
|
||||
* starts outside the viewport.
|
||||
*/
|
||||
export function belowTheFold(c: PanelKindCase): PostableDashboard {
|
||||
const dashboard = buildDashboard({
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
filler: panel(PanelKind.Text, { name: 'Filler' }),
|
||||
[SINGLE_PANEL_ID]: c.panel(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const [layout] = dashboard.spec.layouts;
|
||||
layout.spec.items = [
|
||||
{ ...layout.spec.items[0], x: 0, y: 0, width: 12, height: 40 },
|
||||
{ ...layout.spec.items[1], x: 0, y: 40 },
|
||||
];
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/** Serves the kind's mocked result; a queryless kind sends no query to serve. */
|
||||
export async function respondWithData(
|
||||
queryRange: QueryRangeMock,
|
||||
c: PanelKindCase,
|
||||
): Promise<void> {
|
||||
const body = c.body();
|
||||
if (body) {
|
||||
await queryRange.respond(body);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind's panel plus an empty second section, so Move to section has a target. */
|
||||
export function withSpareSection(c: PanelKindCase): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [
|
||||
{ title: 'Section', panels: { [SINGLE_PANEL_ID]: c.panel() } },
|
||||
{ title: 'Spare', panels: {} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher's reason for refusing `c`, or null when it accepts: the query
|
||||
* type is checked before the signal (getPanelTypeDisabledReason).
|
||||
*/
|
||||
export function disabledReason(
|
||||
c: PanelKindCase,
|
||||
source: { queryType: QueryType; signal?: Signal },
|
||||
): string | null {
|
||||
if (c.queryless) {
|
||||
return null;
|
||||
}
|
||||
if (!c.queryTypes.includes(source.queryType)) {
|
||||
const label = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse',
|
||||
promql: 'PromQL',
|
||||
}[source.queryType];
|
||||
return `${c.label} isn't available for ${label} queries`;
|
||||
}
|
||||
if (source.signal && !c.signals.includes(source.signal)) {
|
||||
return `${c.label} doesn't support ${source.signal} data`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export * from './panel-config';
|
||||
274
tests/e2e/drivers/panels.ts
generated
274
tests/e2e/drivers/panels.ts
generated
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { test as dashboardsTest } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { actionsAt, PanelAction, type PanelActions } from './panels/actions';
|
||||
import { type Chart, chartAt } from './panels/chart';
|
||||
import { openSelectOption } from './panels/dom';
|
||||
import { gridAt, type PanelGrid } from './panels/grid';
|
||||
import {
|
||||
type HeatmapPanel,
|
||||
type HeatmapTooltip,
|
||||
heatmapAt,
|
||||
heatmapTooltipAt,
|
||||
} from './panels/heatmap';
|
||||
import { type Legend, legendAt } from './panels/legend';
|
||||
import { type Markdown, markdownAt } from './panels/markdown';
|
||||
import { type PiePanel, pieAt } from './panels/pie';
|
||||
import {
|
||||
type ListPager,
|
||||
pagerAt,
|
||||
type PanelSearch,
|
||||
searchAt,
|
||||
type TableColumns,
|
||||
tableAt,
|
||||
} from './panels/table';
|
||||
|
||||
// Rendered panels, on the dashboard grid and in the View modal. Each surface
|
||||
// lives in its own part under ./panels, and so does a kind with UI of its own.
|
||||
|
||||
export const PanelMessageText = {
|
||||
noQueryTitle: 'Nothing to visualize yet',
|
||||
// Curly apostrophe (U+2019): a straight quote will not match.
|
||||
errorTitle: 'Couldn’t load panel data',
|
||||
noDataTitle: 'No data in this time range',
|
||||
extendAction: 'Extend time range',
|
||||
retryAction: 'Retry',
|
||||
} as const;
|
||||
|
||||
/** Root testid each kind's renderer puts on its own subtree. */
|
||||
export const RENDERER_TESTID: Record<PanelKind, string> = {
|
||||
[PanelKind.TimeSeries]: 'time-series-renderer',
|
||||
[PanelKind.BarChart]: 'bar-panel-renderer',
|
||||
[PanelKind.AreaChart]: 'area-chart-renderer',
|
||||
[PanelKind.Histogram]: 'histogram-panel-renderer',
|
||||
[PanelKind.Number]: 'number-panel-renderer',
|
||||
[PanelKind.PieChart]: 'pie-panel-renderer',
|
||||
[PanelKind.Table]: 'table-panel-renderer',
|
||||
[PanelKind.List]: 'list-panel-renderer',
|
||||
[PanelKind.Heatmap]: 'heatmap-panel-renderer',
|
||||
[PanelKind.Text]: 'text-panel',
|
||||
};
|
||||
|
||||
export interface PanelHandle {
|
||||
root(): Locator;
|
||||
/** Panels fetch lazily behind an IntersectionObserver. */
|
||||
scrollIntoView(): Promise<void>;
|
||||
/** Scrolls in, then waits for the kind's own renderer. */
|
||||
waitRendered(kind: PanelKind): Promise<void>;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
/** `data-panel-visible`, 'true' once the panel entered the viewport. */
|
||||
visibility(): Locator;
|
||||
title(): Locator;
|
||||
infoIcon(): Locator;
|
||||
timePreference(): Locator;
|
||||
statusWarning(): Locator;
|
||||
statusError(): Locator;
|
||||
loading(): Locator;
|
||||
noData(): Locator;
|
||||
noDataAction(): Locator;
|
||||
noDataSecondaryAction(): Locator;
|
||||
error(): Locator;
|
||||
errorAction(): Locator;
|
||||
text(text: string): Locator;
|
||||
numberValue(): Locator;
|
||||
numberNoData(): Locator;
|
||||
/** Replaces the header when a Text panel hides it; carries the drag class. */
|
||||
hiddenHeaderHandle(): Locator;
|
||||
markdown: Markdown;
|
||||
heatmap: HeatmapPanel;
|
||||
pie: PiePanel;
|
||||
chart: Chart;
|
||||
legend: Legend;
|
||||
actions: PanelActions;
|
||||
search: PanelSearch;
|
||||
pager: ListPager;
|
||||
table: TableColumns;
|
||||
grid: PanelGrid;
|
||||
}
|
||||
|
||||
export interface Panels {
|
||||
panel(id: string): PanelHandle;
|
||||
/** Every panel title on the grid. */
|
||||
titles(): Locator;
|
||||
tooltip: {
|
||||
container(): Locator;
|
||||
list(): Locator;
|
||||
unpin(): Locator;
|
||||
/** The pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still says 'l'. */
|
||||
pin(): Promise<void>;
|
||||
/** Hover tooltips render their content twice, so this scopes to the tooltip role. */
|
||||
statusContent(): Locator;
|
||||
/** Free text inside any open tooltip. */
|
||||
text(text: string): Locator;
|
||||
heatmap: HeatmapTooltip;
|
||||
};
|
||||
drilldown: {
|
||||
menu(): Locator;
|
||||
/** Testids sit on an inner span; this walks up so disabled states assert. */
|
||||
item(testId: string): Locator;
|
||||
back(menu: 'breakout' | 'var'): Locator;
|
||||
breakoutSearch(): Locator;
|
||||
/** A page-level Escape does nothing: the handler needs the backdrop. */
|
||||
dismiss(): Promise<void>;
|
||||
};
|
||||
view: {
|
||||
/** Opens the modal through the actions menu. */
|
||||
open(panelId: string): Promise<Locator>;
|
||||
content(): Locator;
|
||||
dialog(): Locator;
|
||||
refresh(): Locator;
|
||||
switchToEdit(): Locator;
|
||||
chart: Chart;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
markdown: Markdown;
|
||||
/** Text only: the Markdown editor in the query builder's slot. */
|
||||
textEditor(): Locator;
|
||||
/** Changes the kind for this session only. */
|
||||
switchType(label: string): Promise<void>;
|
||||
};
|
||||
/** A visible option of the open antd Select. */
|
||||
openSelectOption(label: string): Locator;
|
||||
/** Fires the browser's Back. */
|
||||
back(): Promise<void>;
|
||||
pressEscape(): Promise<void>;
|
||||
/** Resolves with the tab a click opens. */
|
||||
nextTab(): Promise<Page>;
|
||||
/** Resolves with the suggested filename of the next download. */
|
||||
nextDownload(): Promise<{ suggestedFilename: () => string }>;
|
||||
}
|
||||
|
||||
function panelHandle(page: Page, id: string): PanelHandle {
|
||||
const root = (): Locator => page.locator(`[data-panel-root="${id}"]`);
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
root,
|
||||
async scrollIntoView() {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
},
|
||||
async waitRendered(kind) {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
|
||||
},
|
||||
renderer: (kind) => byTestId(RENDERER_TESTID[kind]),
|
||||
visibility: root,
|
||||
title: () => byTestId('panel-title'),
|
||||
infoIcon: () => byTestId('panel-header-info-icon'),
|
||||
timePreference: () => byTestId('panel-time-preference'),
|
||||
statusWarning: () => byTestId('panel-status-warning'),
|
||||
statusError: () => byTestId('panel-status-error'),
|
||||
loading: () => byTestId('panel-loading'),
|
||||
noData: () => byTestId('panel-no-data'),
|
||||
noDataAction: () => byTestId('panel-no-data-action'),
|
||||
noDataSecondaryAction: () => byTestId('panel-no-data-secondary-action'),
|
||||
error: () => byTestId('panel-error'),
|
||||
errorAction: () => byTestId('panel-error-action'),
|
||||
text: (text) => root().getByText(text),
|
||||
numberValue: () => byTestId('number-panel-value'),
|
||||
numberNoData: () => byTestId('number-panel-no-data'),
|
||||
hiddenHeaderHandle: () => byTestId('hidden-header-drag-handle'),
|
||||
markdown: markdownAt(() => byTestId(RENDERER_TESTID[PanelKind.Text])),
|
||||
heatmap: heatmapAt(root),
|
||||
pie: pieAt(root),
|
||||
chart: chartAt(page, () => byTestId('uplot-main-div')),
|
||||
legend: legendAt(root),
|
||||
actions: actionsAt(page, id, root),
|
||||
search: searchAt(root),
|
||||
pager: pagerAt(page, root),
|
||||
table: tableAt(page, id, root),
|
||||
grid: gridAt(page, root),
|
||||
};
|
||||
}
|
||||
|
||||
function panelsDriver(page: Page): Panels {
|
||||
const menu = (): Locator => page.locator('.context-menu');
|
||||
const viewContent = (): Locator =>
|
||||
page.getByTestId('view-panel-modal-content');
|
||||
|
||||
return {
|
||||
panel: (id) => panelHandle(page, id),
|
||||
titles: () => page.getByTestId('panel-title'),
|
||||
tooltip: {
|
||||
container: () => page.getByTestId('uplot-tooltip-container'),
|
||||
list: () => page.getByTestId('uplot-tooltip-list'),
|
||||
unpin: () => page.getByTestId('uplot-tooltip-unpin'),
|
||||
async pin() {
|
||||
await page.keyboard.press('p');
|
||||
},
|
||||
statusContent: () =>
|
||||
page.getByRole('tooltip').getByTestId('panel-status-content'),
|
||||
text: (text) => page.getByText(text),
|
||||
heatmap: heatmapTooltipAt(page),
|
||||
},
|
||||
drilldown: {
|
||||
menu,
|
||||
item: (testId) =>
|
||||
page
|
||||
.getByTestId(testId)
|
||||
.locator(
|
||||
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
|
||||
),
|
||||
back: (submenu) => page.getByTestId(`drilldown-${submenu}-back`),
|
||||
breakoutSearch: () => page.getByPlaceholder('Search breakout options...'),
|
||||
async dismiss() {
|
||||
// A corner, since the menu opens over the clicked point.
|
||||
await page
|
||||
.locator('.context-menu-backdrop')
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
},
|
||||
},
|
||||
view: {
|
||||
async open(panelId) {
|
||||
await panelHandle(page, panelId).actions.run(PanelAction.view);
|
||||
await expect(viewContent()).toBeVisible();
|
||||
return viewContent();
|
||||
},
|
||||
content: viewContent,
|
||||
dialog: () => page.getByRole('dialog'),
|
||||
refresh: () => page.getByTestId('view-panel-refresh'),
|
||||
switchToEdit: () => page.getByTestId('view-panel-switch-to-edit'),
|
||||
chart: chartAt(page, () => viewContent().getByTestId('uplot-main-div')),
|
||||
renderer: (kind) => viewContent().getByTestId(RENDERER_TESTID[kind]),
|
||||
markdown: markdownAt(() =>
|
||||
viewContent().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
textEditor: () => viewContent().getByTestId('text-panel-editor-pane'),
|
||||
async switchType(label) {
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
},
|
||||
openSelectOption: (label) => openSelectOption(page, label),
|
||||
async back() {
|
||||
await page.goBack();
|
||||
},
|
||||
async pressEscape() {
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
nextTab: () => page.context().waitForEvent('page'),
|
||||
nextDownload: () => page.waitForEvent('download'),
|
||||
};
|
||||
}
|
||||
|
||||
const withPanels = driver('panels', ({ page }) => panelsDriver(page));
|
||||
|
||||
export const test = withPanels(dashboardsTest);
|
||||
|
||||
export * from './dashboards';
|
||||
export * from './panels/actions';
|
||||
export * from './panels/chart';
|
||||
export * from './panels/dom';
|
||||
export * from './panels/grid';
|
||||
export * from './panels/heatmap';
|
||||
export * from './panels/legend';
|
||||
export * from './panels/markdown';
|
||||
export * from './panels/pie';
|
||||
export * from './panels/table';
|
||||
88
tests/e2e/drivers/panels/actions.ts
generated
88
tests/e2e/drivers/panels/actions.ts
generated
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// Panel action menu items take no testid (@signozhq/ui's dropdown preset drops
|
||||
// it), so their visible label is the contract, matched by Radix's menuitem role.
|
||||
|
||||
export const PanelAction = {
|
||||
view: 'View',
|
||||
edit: 'Edit panel',
|
||||
clone: 'Clone',
|
||||
download: 'Download',
|
||||
downloadCsv: 'Download as CSV',
|
||||
downloadPng: 'Download as PNG',
|
||||
downloadSvg: 'Download as SVG',
|
||||
createAlert: 'Create Alerts',
|
||||
move: 'Move to section',
|
||||
moveToRoot: 'Dashboard (root)',
|
||||
delete: 'Delete panel',
|
||||
} as const;
|
||||
|
||||
export interface PanelActions {
|
||||
trigger(): Locator;
|
||||
/** Dismisses any menu still open first: one caught mid-dismiss eats the click. */
|
||||
open(): Promise<Locator>;
|
||||
close(): Promise<void>;
|
||||
item(label: string): Locator;
|
||||
run(label: string): Promise<void>;
|
||||
/** Hovers a parent item to open its submenu; clicking it closes the menu. */
|
||||
hoverItem(label: string): Promise<void>;
|
||||
download(format: 'CSV' | 'PNG' | 'SVG'): Promise<void>;
|
||||
moveTo(sectionTitle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function actionsAt(
|
||||
page: Page,
|
||||
panelId: string,
|
||||
root: () => Locator,
|
||||
): PanelActions {
|
||||
const menuItem = (label: string): Locator =>
|
||||
page.getByRole('menuitem', { name: label, exact: true });
|
||||
|
||||
const actions: PanelActions = {
|
||||
trigger: () => page.getByTestId(`panel-actions-${panelId}`),
|
||||
async open() {
|
||||
await actions.close();
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
// The trigger only appears on hover.
|
||||
await root().hover();
|
||||
await actions.trigger().click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
},
|
||||
async close() {
|
||||
if ((await page.getByRole('menu').count()) === 0) {
|
||||
return;
|
||||
}
|
||||
// An open submenu takes the first Escape for itself.
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menu')).toHaveCount(0, { timeout: 1_000 });
|
||||
}).toPass();
|
||||
},
|
||||
item: menuItem,
|
||||
async run(label) {
|
||||
await actions.open();
|
||||
await menuItem(label).click();
|
||||
},
|
||||
async hoverItem(label) {
|
||||
await menuItem(label).hover();
|
||||
},
|
||||
async download(format) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.download).hover();
|
||||
await menuItem(`Download as ${format}`).click();
|
||||
},
|
||||
async moveTo(sectionTitle) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.move).hover();
|
||||
await menuItem(sectionTitle).click();
|
||||
},
|
||||
};
|
||||
|
||||
return actions;
|
||||
}
|
||||
287
tests/e2e/drivers/panels/chart.ts
generated
287
tests/e2e/drivers/panels/chart.ts
generated
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragHorizontally } from './dom';
|
||||
|
||||
/** uPlot encodes a log scale as `distr: 3`. */
|
||||
export const LOG_DISTR = 3;
|
||||
|
||||
export interface UPlotSeriesState {
|
||||
label?: string;
|
||||
/** false once a series is hidden via the legend. */
|
||||
show: boolean;
|
||||
/** Non-empty for a dashed line style. */
|
||||
dash?: number[];
|
||||
/** uPlot only sets a fill when the panel asks for one. */
|
||||
hasFill: boolean;
|
||||
/** The resolved fill: a colour (`#rrggbbaa` when solid) or 'gradient'. */
|
||||
fill: string | null;
|
||||
/** Largest finite value plotted, after any stacking; null when none. */
|
||||
max: number | null;
|
||||
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
|
||||
pointsShow?: boolean | 'fn' | null;
|
||||
width?: number;
|
||||
/** uPlot's own gap bridging; false when a threshold breaks gaps in data-prep. */
|
||||
spanGaps?: boolean | number;
|
||||
/** Nulls in the plotted data: each one is a break in the line. */
|
||||
nullCount: number;
|
||||
}
|
||||
|
||||
export interface UPlotScaleState {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
/** 1 linear, 3 logarithmic. */
|
||||
distr?: number;
|
||||
}
|
||||
|
||||
export interface UPlotState {
|
||||
/** Excludes the x series at index 0. */
|
||||
series: UPlotSeriesState[];
|
||||
scales: Record<string, UPlotScaleState>;
|
||||
/** Points in the x series. */
|
||||
pointCount: number;
|
||||
/** Fill bands between series; a stacked chart has one per adjacent pair. */
|
||||
bandCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real chart state, read from the `__uplot` handle UPlotChart hangs off its
|
||||
* container. The DOM only says a chart exists; this catches a renderer that
|
||||
* ignores a setting.
|
||||
*/
|
||||
export interface Chart {
|
||||
host(): Locator;
|
||||
/** uPlot binds its cursor to `.u-over`, so this is the pointer target. */
|
||||
plotArea(): Locator;
|
||||
/** Throws when there is no plot, usually because the panel shows No Data. */
|
||||
state(): Promise<UPlotState>;
|
||||
/** The left scale, where axis bounds and log mode land. */
|
||||
yScale(): Promise<UPlotScaleState>;
|
||||
expectSeriesCount(count: number): Promise<void>;
|
||||
/**
|
||||
* Share (0–1) of the plot-area row at `value` on the left scale painted in
|
||||
* `color`. For lines drawn straight onto the canvas, such as thresholds,
|
||||
* which leave no state on the uPlot instance.
|
||||
*/
|
||||
lineCoverage(value: number, color: string): Promise<number>;
|
||||
/** Moves the pointer to the middle of the plot. */
|
||||
hover(): Promise<void>;
|
||||
/** Moves the pointer just outside the plot's top-left corner. */
|
||||
leave(): Promise<void>;
|
||||
click(): Promise<void>;
|
||||
/**
|
||||
* Clicks the first series' data point at `index`, by default the middle of
|
||||
* those inside the visible window, placed through uPlot's own scales so it
|
||||
* lands on data wherever it sits.
|
||||
*/
|
||||
clickPoint(index?: number): Promise<void>;
|
||||
/** Fractions are of the plot area's own box, so panel size does not matter. */
|
||||
drag(fromFraction: number, toFraction: number): Promise<void>;
|
||||
}
|
||||
|
||||
async function readUplot(host: Locator): Promise<UPlotState> {
|
||||
await expect(host).toBeVisible();
|
||||
return host.evaluate((node) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
series: {
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
dash?: number[];
|
||||
fill?: unknown;
|
||||
width?: number;
|
||||
spanGaps?: boolean | number;
|
||||
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
|
||||
}[];
|
||||
scales: Record<string, { min?: number; max?: number; distr?: number }>;
|
||||
data: unknown[][];
|
||||
bands?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!plot) {
|
||||
throw new Error(
|
||||
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
|
||||
);
|
||||
}
|
||||
|
||||
// uPlot wraps every fill in a function; calling it yields the colour or gradient.
|
||||
const resolveFill = (fill: unknown, seriesIndex: number): string | null => {
|
||||
const value =
|
||||
typeof fill === 'function'
|
||||
? (fill as (u: unknown, i: number) => unknown)(plot, seriesIndex)
|
||||
: fill;
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return value instanceof CanvasGradient ? 'gradient' : null;
|
||||
};
|
||||
|
||||
const maxOf = (values: unknown[] | undefined): number | null => {
|
||||
const finite = (values ?? []).filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return finite.length > 0 ? Math.max(...finite) : null;
|
||||
};
|
||||
|
||||
return {
|
||||
series: plot.series.slice(1).map((series, index) => ({
|
||||
label: series.label,
|
||||
show: series.show !== false,
|
||||
dash: series.dash,
|
||||
hasFill: series.fill != null,
|
||||
fill: resolveFill(series.fill, index + 1),
|
||||
max: maxOf(plot.data?.[index + 1]),
|
||||
pointsShow:
|
||||
typeof series.points?.show === 'function'
|
||||
? ('fn' as const)
|
||||
: (series.points?.show ?? null),
|
||||
width: series.width,
|
||||
spanGaps: series.spanGaps,
|
||||
nullCount: (plot.data?.[index + 1] ?? []).filter((v) => v === null).length,
|
||||
})),
|
||||
scales: Object.fromEntries(
|
||||
Object.entries(plot.scales).map(([key, scale]) => [
|
||||
key,
|
||||
{ min: scale.min ?? null, max: scale.max ?? null, distr: scale.distr },
|
||||
]),
|
||||
),
|
||||
pointCount: plot.data?.[0]?.length ?? 0,
|
||||
bandCount: plot.bands?.length ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function chartAt(page: Page, host: () => Locator): Chart {
|
||||
const plotArea = (): Locator => host().locator('.u-over');
|
||||
const chart: Chart = {
|
||||
host,
|
||||
plotArea,
|
||||
state: () => readUplot(host()),
|
||||
async yScale() {
|
||||
const { scales } = await chart.state();
|
||||
// Panels name the left scale 'y'; fall back to the first non-x scale.
|
||||
return (
|
||||
scales.y ??
|
||||
Object.entries(scales).find(([key]) => key !== 'x')?.[1] ?? {
|
||||
min: null,
|
||||
max: null,
|
||||
}
|
||||
);
|
||||
},
|
||||
async lineCoverage(value, color) {
|
||||
await expect(host()).toBeVisible();
|
||||
return host().evaluate(
|
||||
(node, { value, color }) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
bbox: { left: number; width: number };
|
||||
scales: Record<string, unknown>;
|
||||
valToPos: (v: number, scale: string, canvas: boolean) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const scale =
|
||||
'y' in plot.scales
|
||||
? 'y'
|
||||
: (Object.keys(plot.scales).find((key) => key !== 'x') ?? 'y');
|
||||
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
if (!probe) {
|
||||
throw new Error('no 2d context to resolve the colour');
|
||||
}
|
||||
probe.fillStyle = color;
|
||||
probe.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
// The line is 2px wide and anti-aliased: read the rows around it and
|
||||
// count a column when any of them is close to the colour.
|
||||
const y = Math.round(plot.valToPos(value, scale, true));
|
||||
const x = Math.round(plot.bbox.left);
|
||||
const width = Math.round(plot.bbox.width);
|
||||
const { data } = plot.ctx.getImageData(x, y - 1, width, 3);
|
||||
let hits = 0;
|
||||
for (let column = 0; column < width; column++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const i = (row * width + column) * 4;
|
||||
const close =
|
||||
data[i + 3] > 0 &&
|
||||
Math.abs(data[i] - r) < 60 &&
|
||||
Math.abs(data[i + 1] - g) < 60 &&
|
||||
Math.abs(data[i + 2] - b) < 60;
|
||||
if (close) {
|
||||
hits += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return width > 0 ? hits / width : 0;
|
||||
},
|
||||
{ value, color },
|
||||
);
|
||||
},
|
||||
async expectSeriesCount(count) {
|
||||
await expect
|
||||
.poll(async () => (await chart.state()).series.length)
|
||||
.toBe(count);
|
||||
},
|
||||
async hover() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async leave() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x - 20, box.y - 20);
|
||||
},
|
||||
async click() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async clickPoint(index) {
|
||||
await expect(host()).toBeVisible();
|
||||
const point = await host().evaluate((node, at) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
data: [number[], ...(number | null)[][]];
|
||||
scales: { x: { min?: number; max?: number } };
|
||||
series: { scale?: string }[];
|
||||
over: HTMLElement;
|
||||
valToPos: (value: number, scale: string) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const xs = plot.data[0];
|
||||
const { min = -Infinity, max = Infinity } = plot.scales.x;
|
||||
const visible = xs
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(({ x }) => x >= min && x <= max);
|
||||
const i = at ?? visible[Math.floor(visible.length / 2)]?.i ?? 0;
|
||||
const y = plot.data[1]?.[i];
|
||||
if (y == null) {
|
||||
throw new Error(`no value at index ${i} of the first series`);
|
||||
}
|
||||
const over = plot.over.getBoundingClientRect();
|
||||
return {
|
||||
x: over.left + plot.valToPos(xs[i], 'x'),
|
||||
y: over.top + plot.valToPos(y, plot.series[1].scale ?? 'y'),
|
||||
};
|
||||
}, index);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
},
|
||||
async drag(fromFraction, toFraction) {
|
||||
const box = await boxOf(plotArea(), 'the plot area');
|
||||
await dragHorizontally(page, box, fromFraction, toFraction);
|
||||
},
|
||||
};
|
||||
return chart;
|
||||
}
|
||||
57
tests/e2e/drivers/panels/dom.ts
generated
57
tests/e2e/drivers/panels/dom.ts
generated
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export async function boxOf(
|
||||
locator: Locator,
|
||||
description: string,
|
||||
): Promise<Box> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
/** The extra pre-mousedown move gives uPlot a cursor anchor to drag from. */
|
||||
export async function dragHorizontally(
|
||||
page: Page,
|
||||
box: Box,
|
||||
fromFraction: number,
|
||||
toFraction: number,
|
||||
): Promise<void> {
|
||||
const y = box.y + box.height / 2;
|
||||
const startX = box.x + box.width * fromFraction;
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * toFraction, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function dragBy(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
steps: number,
|
||||
): Promise<void> {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** A visible option of whichever antd Select is open. */
|
||||
export function openSelectOption(page: Page, label: string): Locator {
|
||||
return page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText(label, { exact: true });
|
||||
}
|
||||
58
tests/e2e/drivers/panels/grid.ts
generated
58
tests/e2e/drivers/panels/grid.ts
generated
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragBy } from './dom';
|
||||
|
||||
/**
|
||||
* react-grid-layout exposes no testids; `.panel-drag-handle` and
|
||||
* `.react-resizable-handle` are the class contract it is configured with
|
||||
* (draggableHandle / draggableCancel in SectionGrid).
|
||||
*/
|
||||
export interface PanelGrid {
|
||||
/** The grid item's rendered size in px. */
|
||||
size(): Promise<{ width: number; height: number }>;
|
||||
/** Drags RGL's south-east grip by the given offset. */
|
||||
resize(dx: number, dy: number): Promise<void>;
|
||||
/** Drags the header handle across its own width. */
|
||||
dragByHeader(): Promise<void>;
|
||||
}
|
||||
|
||||
export function gridAt(page: Page, root: () => Locator): PanelGrid {
|
||||
// RGL appends the resize grip to its grid item, a sibling of the panel root.
|
||||
const gridItem = (): Locator =>
|
||||
root().locator('xpath=ancestor::div[contains(@class,"react-grid-item")][1]');
|
||||
return {
|
||||
async size() {
|
||||
const { width, height } = await boxOf(gridItem(), 'the grid item');
|
||||
return { width, height };
|
||||
},
|
||||
async resize(dx, dy) {
|
||||
const grip = await boxOf(
|
||||
gridItem().locator('.react-resizable-handle'),
|
||||
'the resize handle',
|
||||
);
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 },
|
||||
{ x: grip.x + dx, y: grip.y + dy },
|
||||
12,
|
||||
);
|
||||
},
|
||||
async dragByHeader() {
|
||||
const bar = await boxOf(
|
||||
root().locator('.panel-drag-handle').first(),
|
||||
'the drag handle',
|
||||
);
|
||||
const y = bar.y + bar.height / 2;
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: bar.x + bar.width / 4, y },
|
||||
{ x: bar.x + bar.width, y },
|
||||
15,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
tests/e2e/drivers/panels/heatmap.ts
generated
34
tests/e2e/drivers/panels/heatmap.ts
generated
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface HeatmapPanel {
|
||||
/** The count colour bar under the grid: caption, min label, ramp, max label. */
|
||||
colorBar(): Locator;
|
||||
/** Marks the hovered cell's count on the bar. */
|
||||
colorBarMarker(): Locator;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
root(): Locator;
|
||||
bucket(): Locator;
|
||||
count(): Locator;
|
||||
}
|
||||
|
||||
export function heatmapAt(root: () => Locator): HeatmapPanel {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
colorBar: () => byTestId('color-bar'),
|
||||
colorBarMarker: () => byTestId('color-bar-marker'),
|
||||
};
|
||||
}
|
||||
|
||||
export function heatmapTooltipAt(page: Page): HeatmapTooltip {
|
||||
return {
|
||||
root: () => page.getByTestId('heatmap-tooltip'),
|
||||
bucket: () => page.getByTestId('heatmap-tooltip-bucket'),
|
||||
count: () => page.getByTestId('heatmap-tooltip-count'),
|
||||
};
|
||||
}
|
||||
52
tests/e2e/drivers/panels/legend.ts
generated
52
tests/e2e/drivers/panels/legend.ts
generated
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
export interface Legend {
|
||||
item(label: string): Locator;
|
||||
items(): Locator;
|
||||
/**
|
||||
* Clicks the row: with every series shown it solos this one, with only this
|
||||
* one shown it restores all, otherwise it toggles this one.
|
||||
*/
|
||||
solo(label: string): Promise<void>;
|
||||
/** The marker toggles just that series; disabled on the last one shown. */
|
||||
toggle(label: string): Promise<void>;
|
||||
/** The row's hover-revealed "Only" / "All" button. */
|
||||
scope(label: string): Locator;
|
||||
/** Rows are `role="switch"`; `aria-checked` carries the shown state. */
|
||||
expectShown(label: string, shown: boolean): Promise<void>;
|
||||
/** Rendered for a right legend, and a bottom one once its rows overflow. */
|
||||
search(): Locator;
|
||||
/** "Showing N of M series", rendered with the search box. */
|
||||
status(): Locator;
|
||||
}
|
||||
|
||||
export function legendAt(root: () => Locator): Legend {
|
||||
const legendItem = (label: string): Locator =>
|
||||
root().locator('[data-legend-item-id]').filter({ hasText: label });
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
item: legendItem,
|
||||
items: () => root().locator('[data-legend-item-id]'),
|
||||
async solo(label) {
|
||||
// The label, not the row centre: the scope button overlays its tail.
|
||||
await legendItem(label).getByText(label).click();
|
||||
},
|
||||
async toggle(label) {
|
||||
await legendItem(label).locator('[data-is-legend-marker]').click();
|
||||
},
|
||||
scope: (label) => legendItem(label).locator('[data-testid^="legend-scope-"]'),
|
||||
async expectShown(label, shown) {
|
||||
await expect(legendItem(label)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
String(shown),
|
||||
);
|
||||
},
|
||||
search: () => byTestId('legend-search-input'),
|
||||
status: () => byTestId('legend-status'),
|
||||
};
|
||||
}
|
||||
27
tests/e2e/drivers/panels/markdown.ts
generated
27
tests/e2e/drivers/panels/markdown.ts
generated
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** A rendered Text panel body. */
|
||||
export interface Markdown {
|
||||
root(): Locator;
|
||||
heading(name: string): Locator;
|
||||
listItems(): Locator;
|
||||
link(name: string): Locator;
|
||||
codeBlock(): Locator;
|
||||
/** "Nothing written yet", for a blank body. */
|
||||
empty(): Locator;
|
||||
}
|
||||
|
||||
export function markdownAt(root: () => Locator): Markdown {
|
||||
return {
|
||||
root,
|
||||
heading: (name) => root().getByRole('heading', { name }),
|
||||
listItems: () => root().getByRole('listitem'),
|
||||
link: (name) => root().getByRole('link', { name }),
|
||||
codeBlock: () => root().locator('pre code'),
|
||||
empty: () => root().getByTestId('text-panel-empty'),
|
||||
};
|
||||
}
|
||||
22
tests/e2e/drivers/panels/pie.ts
generated
22
tests/e2e/drivers/panels/pie.ts
generated
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** The donut @visx draws: one `<path>` per visible slice, the total in the hole. */
|
||||
export interface PiePanel {
|
||||
arcs(): Locator;
|
||||
/** The total of the visible slices, centred in the donut hole. */
|
||||
total(): Locator;
|
||||
}
|
||||
|
||||
export function pieAt(root: () => Locator): PiePanel {
|
||||
// The legend's icons are SVGs too; the donut is the first one.
|
||||
const chart = (): Locator =>
|
||||
root().getByTestId('pie-chart').locator('svg').first();
|
||||
return {
|
||||
arcs: () => chart().locator('g > path'),
|
||||
total: () => chart().locator('text[text-anchor="middle"]'),
|
||||
};
|
||||
}
|
||||
105
tests/e2e/drivers/panels/table.ts
generated
105
tests/e2e/drivers/panels/table.ts
generated
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { storageKey } from '../../helpers/base-path';
|
||||
|
||||
import { boxOf, dragBy, openSelectOption } from './dom';
|
||||
|
||||
/** Local storage key column widths persist under, behind a 400ms debounce. */
|
||||
const COLUMN_WIDTHS_KEY = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS';
|
||||
|
||||
/** Header search, offered by Table and List. */
|
||||
export interface PanelSearch {
|
||||
trigger(): Locator;
|
||||
input(): Locator;
|
||||
clear(): Locator;
|
||||
open(term: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** List pagination. */
|
||||
export interface ListPager {
|
||||
page(): Locator;
|
||||
prev(): Locator;
|
||||
next(): Locator;
|
||||
/** Picks e.g. '50 / page' from the page-size select. */
|
||||
setPageSize(label: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TableColumns {
|
||||
rows(): Locator;
|
||||
header(column: string): Locator;
|
||||
/** Rendered header width in px, 0 when not laid out. */
|
||||
headerWidth(column: string): Promise<number>;
|
||||
resizeColumn(column: string, byPx: number): Promise<void>;
|
||||
/** The width persisted for this column, 0 when none. */
|
||||
storedWidth(column: string): Promise<number>;
|
||||
}
|
||||
|
||||
export function searchAt(root: () => Locator): PanelSearch {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
trigger: () => byTestId('panel-header-search-trigger'),
|
||||
input: () => byTestId('panel-header-search-input'),
|
||||
clear: () => byTestId('panel-header-search-clear'),
|
||||
async open(term) {
|
||||
await root().hover();
|
||||
await byTestId('panel-header-search-trigger').click();
|
||||
await byTestId('panel-header-search-input').fill(term);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pagerAt(page: Page, root: () => Locator): ListPager {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
page: () => byTestId('list-panel-page'),
|
||||
prev: () => byTestId('list-panel-prev'),
|
||||
next: () => byTestId('list-panel-next'),
|
||||
async setPageSize(label) {
|
||||
await byTestId('list-panel-page-size').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function tableAt(
|
||||
page: Page,
|
||||
panelId: string,
|
||||
root: () => Locator,
|
||||
): TableColumns {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
const header = (column: string): Locator =>
|
||||
root().locator('th').filter({ hasText: column });
|
||||
return {
|
||||
rows: () => root().locator('tbody tr.ant-table-row'),
|
||||
header,
|
||||
async headerWidth(column) {
|
||||
return (await header(column).boundingBox())?.width ?? 0;
|
||||
},
|
||||
async resizeColumn(column, byPx) {
|
||||
const grip = await boxOf(
|
||||
byTestId(`column-resize-${column}`),
|
||||
`the ${column} resize grip`,
|
||||
);
|
||||
const y = grip.y + grip.height / 2;
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: grip.x + grip.width / 2, y },
|
||||
{ x: grip.x + byPx, y },
|
||||
10,
|
||||
);
|
||||
},
|
||||
storedWidth: (column) =>
|
||||
page.evaluate(
|
||||
({ key, panelId, name }) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
const widths = raw ? JSON.parse(raw) : {};
|
||||
return (widths?.[panelId]?.[name] as number | undefined) ?? 0;
|
||||
},
|
||||
{ key: storageKey(COLUMN_WIDTHS_KEY), panelId, name: column },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
// Typed builders for the (Perses-shaped) dashboard spec — pure data, no
|
||||
// Playwright. Mirrors Go's `dashboardtypes.DashboardSpec`.
|
||||
//
|
||||
// Three server rules that surface as an opaque 400:
|
||||
// 1. POST decodes with DisallowUnknownFields — any stray key rejects it all.
|
||||
// 2. Every panel needs EXACTLY ONE query; Text needs `queries: []`.
|
||||
// 3. Panel keys match /^[a-zA-Z0-9_.-]+$/, so readable ids double as test
|
||||
// handles (`panel-actions-${id}`).
|
||||
|
||||
// ─── Kinds ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const PanelKind = {
|
||||
TimeSeries: 'signoz/TimeSeriesPanel',
|
||||
BarChart: 'signoz/BarChartPanel',
|
||||
AreaChart: 'signoz/AreaChartPanel',
|
||||
Histogram: 'signoz/HistogramPanel',
|
||||
Number: 'signoz/NumberPanel',
|
||||
PieChart: 'signoz/PieChartPanel',
|
||||
Table: 'signoz/TablePanel',
|
||||
List: 'signoz/ListPanel',
|
||||
Heatmap: 'signoz/HeatmapPanel',
|
||||
Text: 'signoz/TextPanel',
|
||||
} as const;
|
||||
|
||||
export type PanelKind = (typeof PanelKind)[keyof typeof PanelKind];
|
||||
|
||||
export type Signal = 'metrics' | 'logs' | 'traces';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MetricAggregation {
|
||||
metricName: string;
|
||||
timeAggregation: string;
|
||||
spaceAggregation: string;
|
||||
reduceTo: string;
|
||||
}
|
||||
|
||||
/** Logs/traces aggregations are expression-shaped ("count()"), not metric-shaped. */
|
||||
export interface ExpressionAggregation {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface OrderBy {
|
||||
key: { name: string };
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface BuilderQuerySpec {
|
||||
name: string;
|
||||
signal: Signal;
|
||||
aggregations: (MetricAggregation | ExpressionAggregation)[];
|
||||
filter?: { expression: string };
|
||||
groupBy?: { name: string }[];
|
||||
order?: OrderBy[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type QueryPlugin =
|
||||
| { kind: 'signoz/BuilderQuery'; spec: BuilderQuerySpec }
|
||||
| { kind: 'signoz/PromQLQuery'; spec: { name: string; query: string } }
|
||||
| { kind: 'signoz/ClickHouseSQL'; spec: { name: string; query: string } };
|
||||
|
||||
/**
|
||||
* List reads `raw`, Heatmap `heatmap`; every other kind reads `time_series`.
|
||||
* Mixing them renders empty.
|
||||
*/
|
||||
export type QueryResultKind = 'time_series' | 'raw' | 'heatmap';
|
||||
|
||||
export interface Query {
|
||||
kind: QueryResultKind;
|
||||
spec: { plugin: QueryPlugin };
|
||||
}
|
||||
|
||||
// ─── Panel plugin specs ──────────────────────────────────────────────────
|
||||
|
||||
export interface Threshold {
|
||||
value: number;
|
||||
color: string;
|
||||
operator?:
|
||||
| 'above'
|
||||
| 'aboveOrEqual'
|
||||
| 'below'
|
||||
| 'belowOrEqual'
|
||||
| 'equal'
|
||||
| 'notEqual';
|
||||
format?: 'background' | 'text';
|
||||
unit?: string;
|
||||
label?: string;
|
||||
columnName?: string;
|
||||
}
|
||||
|
||||
export type StackMode = 'none' | 'normal' | 'percent';
|
||||
|
||||
export type HeatmapPalette =
|
||||
| 'ice'
|
||||
| 'moss'
|
||||
| 'rust'
|
||||
| 'graphite'
|
||||
| 'ember'
|
||||
| 'lagoon'
|
||||
| 'orchid'
|
||||
| 'verdant'
|
||||
| 'lava'
|
||||
| 'beacon';
|
||||
|
||||
export interface HeatmapColors {
|
||||
mode?: 'palette' | 'opacity';
|
||||
palette?: HeatmapPalette;
|
||||
scale?: 'log' | 'sqrt' | 'linear';
|
||||
/** 2–128. */
|
||||
steps?: number;
|
||||
minCount?: number | null;
|
||||
maxCount?: number | null;
|
||||
/** Opacity mode only. */
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export interface TextPresentation {
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
/** A hex colour. */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bag for every kind; the server's strict decoder rejects a key the kind
|
||||
* does not declare, so a mismatch fails the seed rather than rendering wrong.
|
||||
*/
|
||||
export interface PanelPluginSpec {
|
||||
visualization?: {
|
||||
timePreference?: string;
|
||||
fillSpans?: boolean;
|
||||
stackedBarChart?: boolean;
|
||||
/** Area only. */
|
||||
stack?: StackMode;
|
||||
};
|
||||
formatting?: {
|
||||
unit?: string;
|
||||
decimalPrecision?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
};
|
||||
axes?: {
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
/** Heatmap only, and its only axis key. */
|
||||
y?: { scale: 'auto' | 'linear' | 'log' | 'symlog' };
|
||||
};
|
||||
legend?: {
|
||||
position?: 'bottom' | 'right';
|
||||
customColors?: Record<string, string>;
|
||||
};
|
||||
chartAppearance?: {
|
||||
lineStyle?: 'solid' | 'dashed';
|
||||
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
|
||||
/** Area drops `none`. */
|
||||
fillMode?: 'none' | 'solid' | 'gradient';
|
||||
/** Area only, 0–1. */
|
||||
fillOpacity?: number;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
|
||||
/** Heatmap only, and its only appearance key. */
|
||||
colors?: HeatmapColors;
|
||||
};
|
||||
histogramBuckets?: {
|
||||
bucketCount?: number;
|
||||
bucketWidth?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
};
|
||||
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
|
||||
thresholds?: Threshold[];
|
||||
/** Text only. */
|
||||
mode?: 'markdown';
|
||||
text?: string;
|
||||
presentation?: TextPresentation;
|
||||
headerOptions?: { hide: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses' link model — NOT the editor dialog's field names. The dialog labels
|
||||
* its first field "Label", but it persists as `name`; there is no `label` key
|
||||
* and the strict decoder rejects one.
|
||||
*/
|
||||
export interface ContextLink {
|
||||
name: string;
|
||||
url: string;
|
||||
tooltip?: string;
|
||||
renderVariables?: boolean;
|
||||
targetBlank?: boolean;
|
||||
}
|
||||
|
||||
export interface Panel {
|
||||
kind: 'Panel';
|
||||
spec: {
|
||||
display: { name: string; description?: string };
|
||||
links: ContextLink[];
|
||||
plugin: { kind: PanelKind; spec: PanelPluginSpec };
|
||||
/** Empty for Text, exactly one query for every other kind. */
|
||||
queries: Query[];
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GridItem {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: { $ref: string };
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
kind: 'Grid';
|
||||
spec: { display: { title: string }; items: GridItem[] };
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export type Variable =
|
||||
| {
|
||||
kind: 'ListVariable';
|
||||
spec: {
|
||||
name: string;
|
||||
display: { name: string };
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: 'none' | 'alphabetical-asc' | 'alphabetical-desc';
|
||||
plugin:
|
||||
| { kind: 'signoz/CustomVariable'; spec: { customValue: string } }
|
||||
| { kind: 'signoz/QueryVariable'; spec: { queryValue: string } }
|
||||
| {
|
||||
kind: 'signoz/DynamicVariable';
|
||||
spec: { name: string; signal: Signal };
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: 'TextVariable';
|
||||
spec: { name: string; display: { name: string }; value: string };
|
||||
};
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardSpec {
|
||||
display: { name: string; description?: string };
|
||||
variables: Variable[];
|
||||
panels: Record<string, Panel>;
|
||||
layouts: Layout[];
|
||||
links: ContextLink[];
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PostableDashboard {
|
||||
schemaVersion: 'v6';
|
||||
/** Must be EMPTY when `generateName` is true; the server derives it. */
|
||||
name: string;
|
||||
generateName: boolean;
|
||||
tags: Tag[];
|
||||
spec: DashboardSpec;
|
||||
}
|
||||
|
||||
/** The backend pins this; a mismatch fails validation before anything else. */
|
||||
export const SCHEMA_VERSION = 'v6' as const;
|
||||
|
||||
// ─── Golden-dataset constants ────────────────────────────────────────────
|
||||
//
|
||||
// What `seed/golden` writes: four metrics over 8 services, 6h of 5-min buckets.
|
||||
|
||||
export const GOLDEN = {
|
||||
metrics: {
|
||||
calls: 'signoz_calls_total',
|
||||
latencyCount: 'signoz_latency_count',
|
||||
latencySum: 'signoz_latency_sum',
|
||||
dbLatencyCount: 'signoz_db_latency_count',
|
||||
},
|
||||
services: [
|
||||
'adservice',
|
||||
'cartservice',
|
||||
'checkoutservice',
|
||||
'currencyservice',
|
||||
'frontend',
|
||||
'paymentservice',
|
||||
'productcatalogservice',
|
||||
'shippingservice',
|
||||
],
|
||||
environment: 'production',
|
||||
/** Golden telemetry spans the last 6 hours, so windows wider than this add nothing. */
|
||||
windowHours: 6,
|
||||
} as const;
|
||||
|
||||
// ─── Query builders ──────────────────────────────────────────────────────
|
||||
|
||||
/** A rate-over-counter metrics query grouped by service — the default fixture query. */
|
||||
export function metricsQuery(options?: {
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
timeAggregation?: string;
|
||||
spaceAggregation?: string;
|
||||
reduceTo?: string;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: options?.metricName ?? GOLDEN.metrics.calls,
|
||||
timeAggregation: options?.timeAggregation ?? 'rate',
|
||||
spaceAggregation: options?.spaceAggregation ?? 'sum',
|
||||
reduceTo: options?.reduceTo ?? 'sum',
|
||||
},
|
||||
],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `raw` logs/traces query for List panels — ordered newest-first like the UI seeds it. */
|
||||
export function rawQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
limit?: number;
|
||||
}): Query {
|
||||
const signal = options?.signal ?? 'logs';
|
||||
return {
|
||||
kind: 'raw',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: [],
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A logs/traces `count()` shaped as `time_series` — what Table and the chart
|
||||
* kinds read (unlike `rawQuery`, which only List consumes). Also the only
|
||||
* non-metrics seed that saves without picking a metric.
|
||||
*/
|
||||
export function logsCountQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: options?.signal ?? 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A metrics builder query shaped as `heatmap`, the only result kind Heatmap reads. */
|
||||
export function heatmapQuery(
|
||||
options?: Parameters<typeof metricsQuery>[0],
|
||||
): Query {
|
||||
return { ...metricsQuery(options), kind: 'heatmap' };
|
||||
}
|
||||
|
||||
export function promqlQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
export function clickhouseQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/ClickHouseSQL', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Panel builder ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PanelOptions {
|
||||
name: string;
|
||||
description?: string;
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/**
|
||||
* Defaults to a metrics query shaped for the kind: `raw` for List, `heatmap`
|
||||
* for Heatmap. Ignored for Text, which carries none.
|
||||
*/
|
||||
query?: Query;
|
||||
links?: ContextLink[];
|
||||
}
|
||||
|
||||
function defaultQueryFor(kind: PanelKind): Query {
|
||||
if (kind === PanelKind.List) {
|
||||
return rawQuery();
|
||||
}
|
||||
if (kind === PanelKind.Heatmap) {
|
||||
return heatmapQuery();
|
||||
}
|
||||
return metricsQuery();
|
||||
}
|
||||
|
||||
export function panel(kind: PanelKind, options: PanelOptions): Panel {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: {
|
||||
name: options.name,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
links: options.links ?? [],
|
||||
plugin: { kind, spec: options.pluginSpec ?? {} },
|
||||
queries:
|
||||
kind === PanelKind.Text ? [] : [options.query ?? defaultQueryFor(kind)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dashboard composer ──────────────────────────────────────────────────
|
||||
|
||||
export interface SectionFixture {
|
||||
title: string;
|
||||
/** Keys become panel ids AND test handles, so keep them readable and unique. */
|
||||
panels: Record<string, Panel>;
|
||||
}
|
||||
|
||||
export interface DashboardFixtureOptions {
|
||||
/** Defaults to a unique value; the server derives the internal name. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
sections: SectionFixture[];
|
||||
variables?: Variable[];
|
||||
duration?: string;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
// pid distinguishes workers; the counter distinguishes seeds within one.
|
||||
let fixtureSeq = 0;
|
||||
function nextFixtureId(): string {
|
||||
fixtureSeq += 1;
|
||||
return `${process.pid}-${fixtureSeq}`;
|
||||
}
|
||||
|
||||
/** Grid is 12 columns wide (SectionGrid `cols`), so 6×6 tiles two per row. */
|
||||
const PANEL_WIDTH = 6;
|
||||
const PANEL_HEIGHT = 6;
|
||||
const GRID_COLS = 12;
|
||||
const PER_ROW = GRID_COLS / PANEL_WIDTH;
|
||||
|
||||
/** Two panels per row. Duplicate ids across sections are rejected loudly. */
|
||||
export function buildDashboard(
|
||||
options: DashboardFixtureOptions,
|
||||
): PostableDashboard {
|
||||
const panels: Record<string, Panel> = {};
|
||||
const layouts: Layout[] = [];
|
||||
|
||||
for (const section of options.sections) {
|
||||
const items: GridItem[] = [];
|
||||
let index = 0;
|
||||
for (const [id, panelSpec] of Object.entries(section.panels)) {
|
||||
if (panels[id]) {
|
||||
throw new Error(`duplicate panel id ${id} in dashboard fixture`);
|
||||
}
|
||||
panels[id] = panelSpec;
|
||||
items.push({
|
||||
x: (index % PER_ROW) * PANEL_WIDTH,
|
||||
y: Math.floor(index / PER_ROW) * PANEL_HEIGHT,
|
||||
width: PANEL_WIDTH,
|
||||
height: PANEL_HEIGHT,
|
||||
content: { $ref: `#/spec/panels/${id}` },
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: section.title }, items },
|
||||
});
|
||||
}
|
||||
|
||||
const title = options.title ?? `e2e-dashboard-${nextFixtureId()}`;
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
name: '',
|
||||
generateName: true,
|
||||
tags: options.tags ?? [],
|
||||
spec: {
|
||||
display: {
|
||||
name: title,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
variables: options.variables ?? [],
|
||||
panels,
|
||||
layouts,
|
||||
links: [],
|
||||
...(options.duration === undefined ? {} : { duration: options.duration }),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// Pinned `/api/v5/query_range` payloads. Install BEFORE `page.goto`.
|
||||
//
|
||||
// Prefer golden data and assert STRUCTURE: the dataset's content is
|
||||
// deterministic but `seed/golden` rebases timestamps to `now`, so labels and
|
||||
// counts are stable while exact numbers are not. Reach for this only when the
|
||||
// backend can't promise what the test needs — error/no-data/warning states,
|
||||
// exact numbers for thresholds and units, a known series count, or stable
|
||||
// pagination.
|
||||
//
|
||||
// Envelope (double-nested, easy to get wrong), mirroring `QueryRangeV5200`:
|
||||
// { status, data: { type, meta, warning, data: { results: [...] } } }
|
||||
// `data.type` is the discriminator — a body under the wrong type renders as an
|
||||
// empty panel rather than failing loudly.
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
type RequestType = 'time_series' | 'scalar' | 'raw' | 'trace' | 'heatmap';
|
||||
|
||||
export interface QueryRangeBody {
|
||||
status: string;
|
||||
data: {
|
||||
type: RequestType;
|
||||
data: { results: unknown[] };
|
||||
meta?: Record<string, unknown>;
|
||||
warning?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(type: RequestType, results: unknown[]): QueryRangeBody {
|
||||
return { status: 'success', data: { type, data: { results } } };
|
||||
}
|
||||
|
||||
// ─── Time helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The default end of a generated series: now, floored to the minute, so series
|
||||
* built by separate calls share their timestamps instead of straddling a
|
||||
* millisecond.
|
||||
*/
|
||||
function defaultEndMs(): number {
|
||||
return Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
export interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
/** One count per bucket bound, plus a trailing open-above overflow. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: HeatmapPoint[];
|
||||
}
|
||||
|
||||
/** Evenly spaced epoch-ms timestamps ending at `endMs`. */
|
||||
export function timestamps(
|
||||
count: number,
|
||||
stepMinutes = 5,
|
||||
endMs = defaultEndMs(),
|
||||
): number[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => endMs - (count - 1 - i) * stepMinutes * MINUTE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** A ramp of `count` points from `from` to `to` — readable, non-flat test data. */
|
||||
export function ramp(
|
||||
count: number,
|
||||
from: number,
|
||||
to: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
const step = count > 1 ? (to - from) / (count - 1) : 0;
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
value: from + step * i,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A flat line — use when the assertion is about a specific value, not a shape. */
|
||||
export function flat(
|
||||
count: number,
|
||||
value: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp) => ({
|
||||
timestamp,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` columns of `width` cells, every count non-zero and growing with both
|
||||
* time and bucket, so any hovered cell has a value and the colour domain is known:
|
||||
* 1 … count × width.
|
||||
*/
|
||||
export function heatmapPoints(
|
||||
count: number,
|
||||
width: number,
|
||||
endMs = defaultEndMs(),
|
||||
): HeatmapPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: width }, (_, j) => (i + 1) * (j + 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
export const QueryRange = {
|
||||
/** Series must nest at `results[].aggregations[].series[]`. */
|
||||
timeSeries(series: SeriesSpec[], queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Rows are positional across `[...groupColumns, ...aggregationColumns]`. */
|
||||
scalar(options: {
|
||||
groupColumns?: string[];
|
||||
aggregationColumns?: string[];
|
||||
rows: (string | number)[][];
|
||||
queryName?: string;
|
||||
units?: Record<string, string>;
|
||||
}): QueryRangeBody {
|
||||
const queryName = options.queryName ?? 'A';
|
||||
const group = (options.groupColumns ?? []).map((name) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'group',
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
const aggregation = (options.aggregationColumns ?? []).map((name, index) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: index,
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
return envelope('scalar', [
|
||||
{ queryName, columns: [...group, ...aggregation], data: options.rows },
|
||||
]);
|
||||
},
|
||||
|
||||
/** List rows. `timestamp` is RFC-3339 here, not epoch ms — per the contract. */
|
||||
raw(
|
||||
rows: Record<string, unknown>[],
|
||||
options?: { queryName?: string; nextCursor?: string },
|
||||
): QueryRangeBody {
|
||||
return envelope('raw', [
|
||||
{
|
||||
queryName: options?.queryName ?? 'A',
|
||||
...(options?.nextCursor ? { nextCursor: options.nextCursor } : {}),
|
||||
rows: rows.map((data) => ({
|
||||
timestamp:
|
||||
typeof data.timestamp === 'string'
|
||||
? data.timestamp
|
||||
: new Date(Number(data.timestamp ?? 0)).toISOString(),
|
||||
data,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bucket bounds sit on the aggregation's `meta`; without them the panel
|
||||
* draws no grid. `series: []` is the heatmap's own no-data body.
|
||||
*/
|
||||
heatmap(
|
||||
buckets: number[],
|
||||
series: HeatmapSeriesSpec[],
|
||||
queryName = 'A',
|
||||
): QueryRangeBody {
|
||||
return envelope('heatmap', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: { buckets },
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
values: p.counts,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Successful but empty — drives `panel-no-data`. */
|
||||
empty(queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [{ queryName, aggregations: [] }]);
|
||||
},
|
||||
|
||||
/** Successful with a warning — drives the `panel-status-warning` indicator. */
|
||||
withWarning(body: QueryRangeBody, message: string): QueryRangeBody {
|
||||
return {
|
||||
...body,
|
||||
data: {
|
||||
...body.data,
|
||||
warning: { warnings: [{ message }], message },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Route installers ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockOptions {
|
||||
/** Stop intercepting after N calls, letting later requests hit the backend. */
|
||||
times?: number;
|
||||
}
|
||||
|
||||
/** Fulfil every query_range call with one pinned body. */
|
||||
export async function mockQueryRange(
|
||||
page: Page,
|
||||
body: QueryRangeBody,
|
||||
options?: MockOptions,
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Fail every query_range call — drives `panel-error`. */
|
||||
export async function mockQueryRangeError(
|
||||
page: Page,
|
||||
message = 'mocked query failure',
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: options?.status ?? 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message },
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One body per call, repeating the last — for pagination and retry flows. */
|
||||
export async function mockQueryRangeSequence(
|
||||
page: Page,
|
||||
bodies: QueryRangeBody[],
|
||||
): Promise<void> {
|
||||
let call = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const body = bodies[Math.min(call, bodies.length - 1)];
|
||||
call += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Record request bodies without changing responses. */
|
||||
export function recordQueryRange(page: Page): {
|
||||
requests: Record<string, unknown>[];
|
||||
install: () => Promise<void>;
|
||||
} {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
return {
|
||||
requests,
|
||||
install: async () => {
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const payload = route.request().postDataJSON() as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (payload) {
|
||||
requests.push(payload);
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop the interception so later navigation reaches the real backend again. */
|
||||
export async function unmockQueryRange(page: Page): Promise<void> {
|
||||
await page.unroute(QUERY_RANGE_GLOB);
|
||||
}
|
||||
@@ -36,6 +36,11 @@ for (const file of ['.env', '.env.local']) {
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
|
||||
// behaviour the dashboards specs assert against, so they fail as written.
|
||||
// Remove this once they are updated for the V2 dashboard.
|
||||
testIgnore: ['**/tests/dashboards/**'],
|
||||
|
||||
// All Playwright output lands under artifacts/. One subdir per reporter
|
||||
// plus results/ for per-test artifacts (traces/screenshots/videos).
|
||||
// CI can archive the whole dir with `tar czf artifacts.tgz tests/e2e/artifacts`.
|
||||
|
||||
10
tests/e2e/pnpm-lock.yaml
generated
10
tests/e2e/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
359
tests/e2e/testdata/dashboards/panels.ts
vendored
359
tests/e2e/testdata/dashboards/panels.ts
vendored
@@ -1,359 +0,0 @@
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
clickhouseQuery,
|
||||
buildDashboard,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
panel,
|
||||
promqlQuery,
|
||||
rawQuery,
|
||||
type ContextLink,
|
||||
type PanelPluginSpec,
|
||||
type PostableDashboard,
|
||||
type Query,
|
||||
type Variable,
|
||||
} from '../../helpers/dashboard-spec';
|
||||
|
||||
// Dashboard fixtures for the dashboard suites. TypeScript rather than frozen JSON so
|
||||
// a contract change is a compile error, and panel ids stay visible to the specs
|
||||
// that use them as test handles.
|
||||
let seq = 0;
|
||||
function uniqueSuffix(): string {
|
||||
seq += 1;
|
||||
return `${process.pid}-${seq}`;
|
||||
}
|
||||
|
||||
// ─── All kinds ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALL_KINDS_PANELS = {
|
||||
timeseries: 'ts-panel',
|
||||
bar: 'bar-panel',
|
||||
histogram: 'histogram-panel',
|
||||
number: 'number-panel',
|
||||
pie: 'pie-panel',
|
||||
table: 'table-panel',
|
||||
list: 'list-panel',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One panel of every kind. Only for "every kind renders" and lazy-load — use
|
||||
* `compactDashboard` otherwise; seven panels means seven real queries.
|
||||
*/
|
||||
export function allKindsDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-all-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.bar]: panel(PanelKind.BarChart, {
|
||||
name: 'Calls bar',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.histogram]: panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({
|
||||
metricName: GOLDEN.metrics.latencySum,
|
||||
groupBy: [],
|
||||
}),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.pie]: panel(PanelKind.PieChart, {
|
||||
name: 'Calls share',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.number]: panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Compact (default multi-panel fixture) ───────────────────────────────
|
||||
|
||||
export const COMPACT_PANELS = {
|
||||
timeseries: ALL_KINDS_PANELS.timeseries,
|
||||
table: ALL_KINDS_PANELS.table,
|
||||
list: ALL_KINDS_PANELS.list,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The default multi-panel fixture: three panels, two sections.
|
||||
*
|
||||
* Every panel fires its own `query_range`, and over-seeding was the dominant
|
||||
* load on the single-container stack. These three keep every distinction the
|
||||
* specs assert — TimeSeries (charts, zoom, drilldown, createAlert), Table (CSV
|
||||
* download, search), List (search, no createAlert) — and two sections keep
|
||||
* Move-to-section and the modal's select-then-confirm branch reachable.
|
||||
*/
|
||||
export function compactDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-compact-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[COMPACT_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[COMPACT_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[COMPACT_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Single panel ────────────────────────────────────────────────────────
|
||||
|
||||
export const SINGLE_PANEL_ID = 'solo-panel';
|
||||
|
||||
export interface SinglePanelOptions {
|
||||
title?: string;
|
||||
kind?: PanelKind;
|
||||
panelName?: string;
|
||||
/** Plugin config (legend, axes, formatting, thresholds, …) for the panel. */
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/** Replaces the kind's default query. */
|
||||
query?: Query;
|
||||
/** Context links attached to the panel. */
|
||||
links?: ContextLink[];
|
||||
/** Attaches the `VARIABLE_NAMES` variables, for interpolation cases. */
|
||||
withVariables?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One panel, one section. Fully configurable so specs never reach into the
|
||||
* returned object — post-hoc mutation is four levels deep and breaks silently
|
||||
* when the spec shape moves.
|
||||
*/
|
||||
export function singlePanelDashboard(
|
||||
options: SinglePanelOptions = {},
|
||||
): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: options.title ?? `e2e-single-${Date.now()}-${uniqueSuffix()}`,
|
||||
...(options.withVariables ? { variables: VARIABLES } : {}),
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: panel(options.kind ?? PanelKind.TimeSeries, {
|
||||
name: options.panelName ?? 'Solo panel',
|
||||
...(options.pluginSpec ? { pluginSpec: options.pluginSpec } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
...(options.links ? { links: options.links } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** No panels at all — drives the dashboard empty state and its New Panel CTA. */
|
||||
export function emptyDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-empty-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Query-type coverage ─────────────────────────────────────────────────
|
||||
|
||||
export const QUERY_TYPE_PANELS = {
|
||||
builder: 'qb-panel',
|
||||
promql: 'promql-panel',
|
||||
clickhouse: 'chsql-panel',
|
||||
} as const;
|
||||
|
||||
/** One panel per query type, for the capability and drilldown-gating specs. */
|
||||
export function queryTypesDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-query-types-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Query types',
|
||||
panels: {
|
||||
[QUERY_TYPE_PANELS.builder]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Builder query',
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.promql]: panel(PanelKind.TimeSeries, {
|
||||
name: 'PromQL query',
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.clickhouse]: panel(PanelKind.Table, {
|
||||
name: 'ClickHouse query',
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export const VARIABLE_NAMES = {
|
||||
custom: 'serviceCustom',
|
||||
dynamic: 'serviceDynamic',
|
||||
} as const;
|
||||
|
||||
const VARIABLES: Variable[] = [
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.custom,
|
||||
display: { name: VARIABLE_NAMES.custom },
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
plugin: {
|
||||
kind: 'signoz/CustomVariable',
|
||||
// Closed list: resolves instantly, no backend round-trip.
|
||||
spec: { customValue: GOLDEN.services.slice(0, 3).join(',') },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.dynamic,
|
||||
display: { name: VARIABLE_NAMES.dynamic },
|
||||
allowAllValue: true,
|
||||
allowMultiple: true,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'service.name', signal: 'metrics' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const VARIABLE_PANEL_ID = 'var-panel';
|
||||
|
||||
/** A panel filtered by a dashboard variable — backs the drilldown variable cases. */
|
||||
export function variablesDashboard(
|
||||
title?: string,
|
||||
query?: Query,
|
||||
): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-variables-${Date.now()}-${uniqueSuffix()}`,
|
||||
variables: VARIABLES,
|
||||
sections: [
|
||||
{
|
||||
title: 'Variables',
|
||||
panels: {
|
||||
[VARIABLE_PANEL_ID]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls for $serviceCustom',
|
||||
query:
|
||||
query ??
|
||||
metricsQuery({
|
||||
filter: `service.name = $${VARIABLE_NAMES.custom}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Area / Heatmap / Text ───────────────────────────────────────────────
|
||||
|
||||
export const NEW_KINDS_PANELS = {
|
||||
area: 'area-panel',
|
||||
heatmap: 'heatmap-panel',
|
||||
text: 'md-panel',
|
||||
} as const;
|
||||
|
||||
export const NEW_KINDS_MARKDOWN =
|
||||
'## Runbook\n\nCheck the **checkout** path first.';
|
||||
|
||||
/**
|
||||
* One Area, one Heatmap and one Text panel — the kinds added after the
|
||||
* all-kinds fixture, kept apart so its seven queries don't grow.
|
||||
*/
|
||||
export function newKindsDashboard(title?: string): PostableDashboard {
|
||||
return buildDashboard({
|
||||
title: title ?? `e2e-new-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'New kinds',
|
||||
panels: {
|
||||
[NEW_KINDS_PANELS.area]: panel(PanelKind.AreaChart, {
|
||||
name: 'Calls area',
|
||||
}),
|
||||
[NEW_KINDS_PANELS.heatmap]: panel(PanelKind.Heatmap, {
|
||||
name: 'Calls heatmap',
|
||||
query: heatmapQuery(),
|
||||
}),
|
||||
[NEW_KINDS_PANELS.text]: panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Invalid on purpose: Text must carry `queries: []`; the server rejects this. */
|
||||
export function textPanelWithQueryDashboard(): PostableDashboard {
|
||||
const text = panel(PanelKind.Text, { name: 'Notes' });
|
||||
return buildDashboard({
|
||||
title: `e2e-text-with-query-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: {
|
||||
...text,
|
||||
spec: { ...text.spec, queries: [metricsQuery()] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
type DownloadFormat,
|
||||
expect,
|
||||
PANEL_KINDS,
|
||||
PanelAction,
|
||||
respondWithData,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
withSpareSection,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind's `actions` declaration turns into on the grid — the ⋮
|
||||
// menu, its downloads and Create Alerts, drilldown, and header search. Items
|
||||
// carry no testid, so the menu matches role + visible label.
|
||||
|
||||
const DOWNLOADS: DownloadFormat[] = ['CSV', 'PNG', 'SVG'];
|
||||
|
||||
const ALWAYS_OFFERED = [
|
||||
PanelAction.view,
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
];
|
||||
|
||||
test.describe('Dashboards contract — panel actions', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
const declared = [
|
||||
...(c.createAlert ? [PanelAction.createAlert] : []),
|
||||
...(c.downloads.length > 0 ? [PanelAction.download] : []),
|
||||
];
|
||||
const missing = [PanelAction.createAlert, PanelAction.download].filter(
|
||||
(label) => !declared.includes(label),
|
||||
);
|
||||
|
||||
test(`TC-01 ${c.label}: the actions menu offers the full set its declaration allows`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// A second section, so Move to section has somewhere to go.
|
||||
await dashboards.seedAndOpen(withSpareSection(c));
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
|
||||
for (const label of [...ALWAYS_OFFERED, ...declared]) {
|
||||
await expect(actions.item(label)).toBeVisible();
|
||||
}
|
||||
for (const label of missing) {
|
||||
await expect(actions.item(label)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.downloads.length > 0)) {
|
||||
const missing = DOWNLOADS.filter((format) => !c.downloads.includes(format));
|
||||
|
||||
test(`TC-02 ${c.label}: Download offers ${c.downloads.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
await actions.hoverItem(PanelAction.download);
|
||||
|
||||
for (const format of c.downloads) {
|
||||
await expect(actions.item(`Download as ${format}`)).toBeVisible();
|
||||
}
|
||||
for (const format of missing) {
|
||||
await expect(actions.item(`Download as ${format}`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.drilldown)) {
|
||||
test(`TC-03 ${c.label}: clicking the data opens the drilldown menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-traces')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-breakout')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.drilldown)) {
|
||||
test(`TC-04 ${c.label}: clicking the data opens no menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.headerSearch)) {
|
||||
test(`TC-05 ${c.label}: offers header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.headerSearch)) {
|
||||
test(`TC-06 ${c.label}: does not offer header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
clickhouseQuery,
|
||||
disabledReason,
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
promqlQuery,
|
||||
type QueryType,
|
||||
type SelectOption,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: the panelKind × queryType × signal matrix as the type switcher shows
|
||||
// it. Each kind is checked against each source query: selectable, or disabled
|
||||
// with the reason getPanelTypeDisabledReason gives.
|
||||
|
||||
interface SourceQuery {
|
||||
name: string;
|
||||
queryType: QueryType;
|
||||
signal?: Signal;
|
||||
seed(): PostableDashboard;
|
||||
}
|
||||
|
||||
const SOURCES: SourceQuery[] = [
|
||||
{
|
||||
name: 'a metrics builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'metrics',
|
||||
seed: () => singlePanelDashboard({ query: metricsQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a logs builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'logs',
|
||||
seed: () => singlePanelDashboard({ query: logsCountQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a PromQL query',
|
||||
queryType: 'promql',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'a ClickHouse query',
|
||||
queryType: 'clickhouse',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.Table,
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function optionFor(options: SelectOption[], label: string): SelectOption {
|
||||
const match = options.find((option) => option.label.startsWith(label));
|
||||
expect(
|
||||
match,
|
||||
`expected a "${label}" option in the type switcher`,
|
||||
).toBeDefined();
|
||||
return match as SelectOption;
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — editor capabilities matrix', () => {
|
||||
SOURCES.forEach((source, index) => {
|
||||
const number = String(index + 1).padStart(2, '0');
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const reason = disabledReason(c, source);
|
||||
if (reason === null) {
|
||||
test(`TC-${number} ${c.label}: ${source.name} keeps it selectable`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(false);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(`TC-${number} ${c.label}: ${source.name} disables it with a reason`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(true);
|
||||
|
||||
await editor.typeOption(c.kind).hover();
|
||||
await expect(panels.tooltip.text(reason)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import {
|
||||
AXIS_CONTROLS,
|
||||
emptyDashboard,
|
||||
type EditorControl,
|
||||
expect,
|
||||
kindCase,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PanelKindCase,
|
||||
type PostableDashboard,
|
||||
QUERYABLE_KINDS,
|
||||
QueryTab,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
type ThresholdVariant,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind declares in the editor — the picker tile, its config
|
||||
// sections, query tabs, threshold variant and the List columns editor. Tabs a
|
||||
// kind cannot use are hidden; the type switcher is the one that disables.
|
||||
|
||||
const THRESHOLD_VARIANTS: ThresholdVariant[] = ['label', 'comparison', 'table'];
|
||||
const QUERY_TABS: string[] = Object.values(QueryTab);
|
||||
|
||||
/** A kind other than `c`, so switching to `c` is a real switch. */
|
||||
function switchSourceKind(c: PanelKindCase): PanelKindCase {
|
||||
return kindCase(
|
||||
c.kind === PanelKind.TimeSeries ? PanelKind.BarChart : PanelKind.TimeSeries,
|
||||
);
|
||||
}
|
||||
|
||||
/** The source kind over a query `c` accepts: List takes no metrics. */
|
||||
function switchSource(
|
||||
c: PanelKindCase,
|
||||
source: PanelKindCase,
|
||||
): PostableDashboard {
|
||||
return singlePanelDashboard({
|
||||
kind: source.kind,
|
||||
query:
|
||||
c.signals.length === 0 || c.signals.includes('metrics')
|
||||
? metricsQuery()
|
||||
: logsCountQuery(),
|
||||
});
|
||||
}
|
||||
|
||||
function partition<T>(all: T[], shown: T[]): [T[], T[]] {
|
||||
return [shown, all.filter((item) => !shown.includes(item))];
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — panel editor', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: the New panel drawer offers it and opens its editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_KINDS.length);
|
||||
await expect(newPanel.tile(c.kind)).toBeVisible();
|
||||
await expect(newPanel.tile(c.kind)).toContainText(c.label);
|
||||
|
||||
await newPanel.choose(c.kind);
|
||||
expect(dashboards.param('panelKind')).toBe(c.kind);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const [shownControls, hiddenControls] = partition<EditorControl>(
|
||||
['time-preference'],
|
||||
c.timePreference ? ['time-preference'] : [],
|
||||
);
|
||||
|
||||
test(`TC-02 ${c.label}: declares its sections and no others`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
|
||||
await editor.section.expand(Section.visualization);
|
||||
for (const control of shownControls) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hiddenControls) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.sections.includes(Section.axes))) {
|
||||
const [shown, hidden] = partition(AXIS_CONTROLS, c.axisControls);
|
||||
|
||||
test(`TC-03 ${c.label}: Axes offers ${shown.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
for (const control of shown) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hidden) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const from = switchSourceKind(c);
|
||||
|
||||
test(`TC-04 ${c.label}: switching to it from ${from.label} brings its sections`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(switchSource(c, from), SINGLE_PANEL_ID);
|
||||
for (const title of from.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
|
||||
await editor.switchType(c.kind);
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
const [shown, hidden] = partition(QUERY_TABS, c.queryTabs);
|
||||
|
||||
test(`TC-05 ${c.label}: offers only the query tabs it supports`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const tab of shown) {
|
||||
await expect(editor.query.tab(tab)).toBeVisible();
|
||||
}
|
||||
for (const tab of hidden) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.queryless)) {
|
||||
test(`TC-06 ${c.label}: has no query builder, tabs or columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.text.pane()).toBeVisible();
|
||||
await expect(editor.query.builder()).toHaveCount(0);
|
||||
for (const tab of QUERY_TABS) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.thresholdVariant !== null)) {
|
||||
const variant = c.thresholdVariant as ThresholdVariant;
|
||||
const others = THRESHOLD_VARIANTS.filter((other) => other !== variant);
|
||||
|
||||
test(`TC-07 ${c.label}: Thresholds uses the ${variant} variant`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.thresholds);
|
||||
await expect(config.thresholds.addButton(variant)).toBeVisible();
|
||||
for (const other of others) {
|
||||
await expect(config.thresholds.addButton(other)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.columnsEditor)) {
|
||||
test(`TC-08 ${c.label}: has the columns editor`, async ({
|
||||
dashboards,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(config.listColumns.editor()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS.filter((k) => !k.columnsEditor)) {
|
||||
test(`TC-09 ${c.label}: has no columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(editor.query.builder()).toBeVisible();
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
belowTheFold,
|
||||
expect,
|
||||
noDataOf,
|
||||
PANEL_KINDS,
|
||||
PanelMessageText,
|
||||
QUERYABLE_KINDS,
|
||||
type QueryRangeBody,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: every kind mounts its renderer, fetches lazily, and shows the shared
|
||||
// PanelBody states. States are mock-only: a healthy stack always returns data.
|
||||
|
||||
function required(body: QueryRangeBody | null): QueryRangeBody {
|
||||
expect(body, 'a queryable kind declares mock bodies').not.toBeNull();
|
||||
return body as QueryRangeBody;
|
||||
}
|
||||
|
||||
test.describe('Dashboards contract — rendering', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: mounts its own renderer`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-02 ${c.label}: does not query below the fold until scrolled into view`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(belowTheFold(c));
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'false',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toHaveCount(0);
|
||||
|
||||
await target.scrollIntoView();
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'true',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-03 ${c.label}: an empty result offers Extend time range as primary and Retry as secondary`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(required(c.emptyBody()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
const noData = noDataOf(c, target);
|
||||
await expect(noData.root()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.noDataTitle)).toBeVisible();
|
||||
await expect(noData.action()).toHaveText(PanelMessageText.extendAction);
|
||||
await expect(noData.secondaryAction()).toHaveText(
|
||||
PanelMessageText.retryAction,
|
||||
);
|
||||
await expect(target.heatmap.colorBar()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-04 ${c.label}: a failing query shows the error state and Retry refetches`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// Fail every call, then swap to success before Retry: react-query retries
|
||||
// on its own, so "fail once then succeed" never surfaces the error state.
|
||||
await queryRange.fail('querier exploded');
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.error()).toBeVisible();
|
||||
await expect(target.statusError()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.errorTitle)).toBeVisible();
|
||||
await expect(target.text('querier exploded')).toBeVisible();
|
||||
|
||||
await queryRange.reset();
|
||||
await queryRange.respond(required(c.body()));
|
||||
|
||||
await target.errorAction().click();
|
||||
await expect(target.error()).toHaveCount(0);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-05 ${c.label}: an in-flight first query shows the loading state`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// LOADING, not `panel-refetching` — the refetch spinner needs prior data.
|
||||
const held = await queryRange.hold(required(c.body()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.loading()).toBeVisible();
|
||||
|
||||
held.release();
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
await expect(target.loading()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
206
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
206
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
createApmMetricsDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_TITLE = 'detail-viewing-base';
|
||||
let baseDashboardId = '';
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
|
||||
seedIds.add(baseDashboardId);
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Viewing', () => {
|
||||
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Use the APM dashboard rather than the empty base — empty dashboards
|
||||
// render an onboarding canvas with its own Configure / New Panel
|
||||
// buttons, which duplicate the toolbar testids.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /Last \d+/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.refresh-btn button')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Set auto refresh' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 breadcrumb returns to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, baseDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
|
||||
// events at the breadcrumb's center, defeating even `force: true`.
|
||||
// Dispatching the click directly on the DOM node bypasses hit testing.
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard /' })
|
||||
.dispatchEvent('click');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 tags bar renders for an imported dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// `exact: true` is load-bearing — `apm` is a substring of the
|
||||
// breadcrumb title `APM Metrics`, so a loose match would collide.
|
||||
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
|
||||
await expect(page.getByText(tag, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 section row headers render for APM Metrics', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// known behaviour: APM Metrics fixture has two sections both named
|
||||
// "Overview" — `.first()` deliberately matches whichever renders first.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 at least one panel container renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 no JS pageerror during initial load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Cross-spec: connection with the dashboards-list page ────────────────
|
||||
|
||||
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Search by name, description, or tags...')
|
||||
.fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page.getByRole('tooltip').getByRole('button', { name: 'View' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
539
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
539
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// ─── Per-test seed lifecycle ────────────────────────────────────────────
|
||||
//
|
||||
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
|
||||
// 16 panels — including the duplicate-named "Overview" sections, which the
|
||||
// fixture intentionally ships). Per-test seeding eliminates the "previous
|
||||
// test left the dashboard in a collapsed/renamed state" class of CI flakes
|
||||
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
|
||||
// possible for one test's restore PUT to race the next test's GET, because
|
||||
// the next test does not see the previous test's dashboard at all.
|
||||
//
|
||||
// `serial` mode is no longer required for correctness (tests are hermetic)
|
||||
// but we keep parallel runs intra-file because seed creation is the
|
||||
// per-test cost — running them concurrently inside the worker would just
|
||||
// pile up more concurrent dashboards without helping.
|
||||
let apmDashboardId: string;
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
if (!apmDashboardId) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
|
||||
} catch {
|
||||
// Best-effort cleanup — a failing delete should not mask test
|
||||
// failures the user actually needs to see.
|
||||
} finally {
|
||||
apmDashboardId = '';
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve the `.row-panel` container for a section by traversing up from its
|
||||
* title text. The fixture ships two sections both literally named "Overview"
|
||||
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
|
||||
* holds both the chevron and the settings-icon for that row.
|
||||
*/
|
||||
function sectionRow(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
index = 0,
|
||||
): ReturnType<Page['locator']> {
|
||||
return page
|
||||
.getByText(name, { exact: typeof name === 'string' })
|
||||
.nth(index)
|
||||
.locator('..')
|
||||
.locator('..');
|
||||
}
|
||||
|
||||
async function gotoApmDashboard(page: Page): Promise<void> {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await page
|
||||
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
|
||||
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
|
||||
// effect's dep array. Concretely: if a chevron is clicked while any
|
||||
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
|
||||
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
|
||||
// returns early — and never re-runs when the GET later completes, because
|
||||
// `dashboardLayout` didn't change again. The PUT is *never* fired and
|
||||
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
|
||||
//
|
||||
// Wait until the in-flight dashboard GETs settle so the effect's gate
|
||||
// evaluates to `false` on the next click. We assert this two ways: a panel
|
||||
// from each visible section must render (proves data is hydrated), and
|
||||
// `Latency` (the first panel of the first Overview section) must paint.
|
||||
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
|
||||
* driven by React local state — `setDashboardLayout` updates synchronously
|
||||
* and the (suffixed / unsuffixed) title appears on the next render. We do
|
||||
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
|
||||
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
|
||||
* Persistence does not matter because each test seeds a fresh dashboard.
|
||||
*
|
||||
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
|
||||
* `nav-item-data` subtree intercepts pointer events at the chevron's
|
||||
* position (verified in CI run #26162502354). `.click({ force: true })`
|
||||
* still lands the event at the visual centre and is swallowed by the
|
||||
* overlay; dispatching the click directly on the SVG node bypasses hit
|
||||
* testing entirely and triggers React's `onClick` handler.
|
||||
*/
|
||||
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
|
||||
const chevron = row.locator('.row-icon');
|
||||
await chevron.scrollIntoViewIfNeeded();
|
||||
await expect(chevron).toBeVisible();
|
||||
|
||||
const page = chevron.page();
|
||||
// Register a PUT listener BEFORE the click. The auto-save effect in
|
||||
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
|
||||
// arrives, its `onSuccess` triggers a brief loading-state re-render that
|
||||
// unmounts every `.row-panel`. The next toggle's chevron lookup either
|
||||
// misses (locator times out) or grabs a transient node that detaches
|
||||
// during scroll. Sequencing: dispatch click → await PUT (3 s short
|
||||
// timeout in case auto-save was gated) → wait for the loading spinner
|
||||
// to be absent.
|
||||
const putSettled = page
|
||||
.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
{ timeout: 3_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await chevron.dispatchEvent('click');
|
||||
await putSettled;
|
||||
await expect(page.getByAltText('loading')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll a section to the target collapsed/expanded state, re-clicking if a
|
||||
// toggle is dropped under CI load. `name` has no regex metacharacters.
|
||||
async function setSectionCollapsed(
|
||||
page: Page,
|
||||
name: string,
|
||||
collapsed: boolean,
|
||||
): Promise<void> {
|
||||
const collapsedTitle = new RegExp(`^${name} \\(\\d+ widgets?\\)$`);
|
||||
await expect(async () => {
|
||||
const alreadyCollapsed = (await page.getByText(collapsedTitle).count()) > 0;
|
||||
if (alreadyCollapsed === collapsed) {
|
||||
return;
|
||||
}
|
||||
await toggleSection(
|
||||
sectionRow(page, alreadyCollapsed ? collapsedTitle : name),
|
||||
);
|
||||
expect((await page.getByText(collapsedTitle).count()) > 0).toBe(collapsed);
|
||||
}).toPass({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
|
||||
* pointer-event interception via `dispatchEvent('click')` (same root cause
|
||||
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
|
||||
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
|
||||
* as the chevron, so it suffers the same overlap.
|
||||
*/
|
||||
async function clickSectionSettings(
|
||||
row: ReturnType<Page['locator']>,
|
||||
): Promise<void> {
|
||||
const icon = row.locator('.settings-icon');
|
||||
await icon.scrollIntoViewIfNeeded();
|
||||
await expect(icon).toBeVisible();
|
||||
await icon.dispatchEvent('click');
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Sections', () => {
|
||||
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Passes locally
|
||||
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
|
||||
// chevron click intermittently fails to land its auto-save PUT despite
|
||||
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
|
||||
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
|
||||
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
|
||||
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Metrics" is the third section in the APM fixture and lives below
|
||||
// the fold on the 1280×720 CI viewport. Scroll its title into view and
|
||||
// wait for visibility so the 14×14 chevron is actionable.
|
||||
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
|
||||
await dbMetricsTitle.scrollIntoViewIfNeeded();
|
||||
await expect(dbMetricsTitle).toBeVisible();
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
// After collapse the section title is rewritten to include the count
|
||||
// suffix; assert with a regex so the test is robust to widget-count
|
||||
// drift in the fixture.
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore: chevron-down is the row-icon variant rendered for collapsed
|
||||
// sections. Re-resolve via the new (suffixed) title.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 widget count matches number of panels visible before collapse', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// The first Overview section in the APM fixture holds these four
|
||||
// panels — they're our ground truth for the count assertion below.
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Request rate', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Error percentage', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Top operations', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'Overview', 0));
|
||||
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Collapse "DB Metrics" instead of the first Overview — its widgets
|
||||
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
|
||||
// so collapse/expand transitions can be asserted without colliding
|
||||
// with the duplicate-titled panels in the two Overview sections.
|
||||
// "DB Metrics" lives further down the canvas; scroll into view first
|
||||
// so the panels actually mount (the canvas virtualises off-screen).
|
||||
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await dbCalls.scrollIntoViewIfNeeded();
|
||||
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// While collapsed, "DB Calls RPS" should fully unmount.
|
||||
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
|
||||
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Calls RPS', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
|
||||
|
||||
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Use DB Metrics — its settings popover is guaranteed to render all
|
||||
// three buttons when the section is expanded. WidgetRow.tsx hides
|
||||
// "Remove Section" while a section is collapsed.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Remove Section' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-05 rename a section, restore original name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const renamed = `Renamed Section ${Date.now()}`;
|
||||
|
||||
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
|
||||
await nameInput.click();
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(renameDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await clickSectionSettings(sectionRow(page, renamed));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
|
||||
await restoreInput.click();
|
||||
await restoreInput.fill('DB Metrics');
|
||||
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(restoreDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 cancel section rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'External calls'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByPlaceholder('Enter row name here...');
|
||||
await input.click();
|
||||
await input.fill('Should Not Be Applied');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
|
||||
test.skip('TC-07 add a new panel to a section, then delete it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const panelName = `Test Panel ${Date.now()}`;
|
||||
|
||||
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'New Panel', exact: true })
|
||||
.click();
|
||||
|
||||
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(panelTypeDialog).toBeVisible();
|
||||
await panelTypeDialog.getByTestId('panel-type-graph').click();
|
||||
|
||||
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
|
||||
// a disabled variant when `isSaveDisabled` is true and an enabled
|
||||
// variant when it is false. Under CI load the editor mounts with the
|
||||
// disabled variant first; without `toBeEnabled` the click can hit the
|
||||
// disabled button and the Save dialog never opens.
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
|
||||
// rationale on `toggleSectionAndWaitForPut` above.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
// PUT confirms the panel persisted server-side — more reliable than
|
||||
// waiting on redux state to propagate before navigating back.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await putResponse;
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// The panel ⋮ menu is a Radix `DropdownMenuSimple` — it opens on click,
|
||||
// not hover (see `openPanelMoreMenu` in 21-panel-actions.spec.ts). The
|
||||
// container hover only reveals the kebab (it's `visibility: hidden`
|
||||
// until then); the click toggles the menu. Wait for the menu role to be
|
||||
// visible before clicking Delete.
|
||||
const panelTitle = page.getByText(panelName, { exact: true }).first();
|
||||
await panelTitle.hover();
|
||||
const panelContainer = panelTitle.locator('../..');
|
||||
await panelContainer.scrollIntoViewIfNeeded();
|
||||
await panelContainer.hover();
|
||||
await panelContainer.getByTestId('widget-header-options').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
|
||||
const deletePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await deletePut;
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── New section in edit mode ────────────────────────────────────────────
|
||||
|
||||
test('TC-08 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const sectionName = `Temp Section ${Date.now()}`;
|
||||
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('menuitem', { name: 'New section' }).click();
|
||||
|
||||
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
await expect(newSectionDialog).toBeVisible();
|
||||
await newSectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await newSectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
await expect(newSectionDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(sectionName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await clickSectionSettings(sectionRow(page, sectionName));
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteRowDialog).toBeVisible();
|
||||
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(deleteRowDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Original sections are untouched.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await setSectionCollapsed(page, 'DB Metrics', true);
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await setSectionCollapsed(page, 'External calls', true);
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore both so the test leaves no state behind.
|
||||
await setSectionCollapsed(page, 'DB Metrics', false);
|
||||
await setSectionCollapsed(page, 'External calls', false);
|
||||
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
|
||||
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
|
||||
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
|
||||
await dbPanel.first().scrollIntoViewIfNeeded();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
|
||||
await toggleSection(sectionRow(page, 'DB Metrics'));
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Panels inside the collapsed section unmount, not just hidden.
|
||||
await expect(dbPanel).toHaveCount(0);
|
||||
|
||||
// Restore.
|
||||
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
635
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
635
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createChartDataDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate the same dashboard (clone / delete panels). Run
|
||||
// them serially within the worker so state from one test does not leak into
|
||||
// another's assertions.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
const TIME_SERIES_PANEL = 'Latency';
|
||||
const TABLE_PANEL = 'Top operations';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// `GridCardLayout`'s auto-save effect fires a PUT on initial load when the
|
||||
// local `dashboardLayout` state diverges from the server `layouts`. Under
|
||||
// CI load this PUT can still be in-flight when a test registers its own
|
||||
// `waitForResponse(PUT)` or triggers a mutation (clone / delete), causing
|
||||
// the wrong PUT to be captured or concurrent writes to corrupt layout state.
|
||||
// Drain the in-flight PUT now so every test in this file starts clean.
|
||||
// The try/catch handles dashboards whose layout is already in sync (no PUT).
|
||||
try {
|
||||
await page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
} catch {
|
||||
// No initial-load PUT within 5 s — layout was already synchronised.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click NewWidget's Save button after waiting for it to become enabled.
|
||||
*
|
||||
* Why this helper exists: `container/NewWidget/index.tsx` renders TWO buttons
|
||||
* with `data-testid="new-widget-save"` — a disabled variant when
|
||||
* `isSaveDisabled` is true and an enabled variant when it is false. Under CI
|
||||
* load the editor mounts with the disabled variant first; without
|
||||
* `toBeEnabled` the click can hit the disabled button and the Save dialog
|
||||
* never opens, failing the next assertion with "dialog not found".
|
||||
*/
|
||||
async function clickNewWidgetSave(page: Page): Promise<void> {
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
// `dispatchEvent('click')` — under CI viewport the editor's right-header
|
||||
// can be partially covered by the sidenav's secondary nav panel, and
|
||||
// `.click()` retries are then swallowed by the overlay. The synthetic
|
||||
// click bypasses hit testing and triggers React's `onClick` directly.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the panel container (`.widget-graph-component-container`) for the
|
||||
* panel with the given title. The title is exposed via `data-testid={title}`
|
||||
* on the inner `Typography.Text` — traverse upward to the container so we
|
||||
* can scope the ⋮ icon, search icon, etc. to this panel only.
|
||||
*
|
||||
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
|
||||
* disambiguated by `index`, defaulting to the first match in DOM order.
|
||||
*/
|
||||
function panelContainer(page: Page, title: string, index = 0): Locator {
|
||||
return page
|
||||
.getByTestId(title)
|
||||
.nth(index)
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the panel header's ⋮ icon and open the action dropdown. Returns the
|
||||
* opened menu locator.
|
||||
*
|
||||
* The ⋮ icon is a `@signozhq/ui` `DropdownMenuSimple` (Radix
|
||||
* `@radix-ui/react-dropdown-menu` under the hood — see `WidgetHeader/index.tsx`),
|
||||
* so the menu opens on click, not hover. We still hover the container first
|
||||
* because the icon is `visibility: hidden` until the row is hovered (see the
|
||||
* `.widget-graph-component-container:hover` rule in `GridCardLayout.styles.scss`);
|
||||
* then we click the revealed icon to toggle the Radix menu open.
|
||||
*/
|
||||
async function openPanelMoreMenu(
|
||||
page: Page,
|
||||
title: string,
|
||||
index = 0,
|
||||
): Promise<Locator> {
|
||||
const container = panelContainer(page, title, index);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
const moreOptions = container.getByTestId('widget-header-options');
|
||||
await moreOptions.click();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
return menu;
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Panel Actions', () => {
|
||||
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
|
||||
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
|
||||
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
|
||||
// because panelTypes !== TABLE.
|
||||
await expect(menu.getByRole('menuitem')).toHaveCount(5);
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'View', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Edit', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Clone', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'Delete', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// Table panels filter CreateAlerts out of the menu (see GridCard
|
||||
// `menuList`) and the Download item turns visible because
|
||||
// panelTypes === TABLE.
|
||||
await expect(
|
||||
menu.getByRole('menuitem', {
|
||||
name: 'Download as CSV',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─── View / Fullscreen ───────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
// The View menuitem is `disabled: queryResponse.isFetching` — wait
|
||||
// for it to become enabled before clicking, otherwise the click is a
|
||||
// no-op and the dialog never opens.
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so a
|
||||
// fully-rendered chart and a "No Data" empty state are both valid
|
||||
// terminal states. Both can also coexist (the chart canvas mounts
|
||||
// before the empty-state overlay paints), so assert that at least
|
||||
// one of the two is reachable rather than using `.or().toBeVisible()`
|
||||
// — that combination triggers strict-mode violations when both
|
||||
// matches resolve.
|
||||
const canvas = dialog.locator('canvas');
|
||||
const noData = dialog.getByText(/no data/i);
|
||||
await expect
|
||||
.poll(async () => (await canvas.count()) + (await noData.count()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Table search ────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-05 Table panel search icon reveals search input', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
|
||||
// The search icon is hover-revealed; click it to swap the title row
|
||||
// out for the search input.
|
||||
const searchIcon = container.getByTestId('widget-header-search');
|
||||
await searchIcon.click();
|
||||
|
||||
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
|
||||
// Typography.Text that carries the title's `data-testid`, so the
|
||||
// `panelContainer` ancestor chain no longer resolves. Look up the
|
||||
// search input by its testid directly — only one search input is
|
||||
// ever open at a time on a dashboard.
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('test');
|
||||
await expect(searchInput).toHaveValue('test');
|
||||
});
|
||||
|
||||
// ─── Download as CSV ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 Download as CSV triggers a file download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// known behaviour: with no telemetry, the CSV may contain only the
|
||||
// header row — asserting on `suggestedFilename()` is the resilient
|
||||
// cross-environment signal that the download actually fired.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'Download as CSV',
|
||||
exact: true,
|
||||
})
|
||||
.click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─── Clone / Delete ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Clone unconditionally navigates to the panel editor (`/new`) — see
|
||||
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
|
||||
// returns to the dashboard with the duplicated panel persisted.
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. The Save dialog
|
||||
// intermittently fails to appear on the GitHub Linux runner after
|
||||
// clicking `new-widget-save` — `clickNewWidgetSave` gates on
|
||||
// `toBeEnabled` + `dispatchEvent('click')` and passes locally (incl.
|
||||
// `STRESS=1 CI=1`) but greens out under CI's slower scheduler in a way
|
||||
// I haven't been able to reproduce. See CI-HARDENING.md.
|
||||
test.skip('TC-07 Clone a panel creates a duplicate', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const cloneItem = menu.getByRole('menuitem', { name: 'Clone', exact: true });
|
||||
await expect(cloneItem).toBeEnabled();
|
||||
await cloneItem.click();
|
||||
|
||||
// The clone handler PUTs the new layout, then redirects to /new.
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
|
||||
// The Save dialog title varies — "Save Widget" if the query is
|
||||
// untouched (the case here, since clone preserves the original
|
||||
// query) or "Unsaved Changes" otherwise. Match either by clicking
|
||||
// OK in whichever dialog appears.
|
||||
const saveDialog = page.getByRole('dialog');
|
||||
await expect(saveDialog).toBeVisible();
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Cleanup the cloned panel — its index is `beforeCount` (the last match).
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
|
||||
// Will work automatically when that test is re-enabled
|
||||
test.skip('TC-08 Delete confirm dialog removes a cloned panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel — never mutate the seed's original
|
||||
// `Latency` panel because sibling specs depend on it.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone — last `Latency` in DOM order.
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/are you sure/i);
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
|
||||
// Will work automatically when that test is re-enabled
|
||||
test.skip('TC-09 Cancel delete keeps the panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel to operate on.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
// Cancel keeps the clone in place — count unchanged from the
|
||||
// post-clone state.
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: actually delete the clone we just kept so
|
||||
// subsequent tests start from the seeded count.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// ─── Create Alerts ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const createAlerts = menu.getByRole('menuitem', {
|
||||
name: /Create Alerts/,
|
||||
});
|
||||
await expect(createAlerts).toBeEnabled();
|
||||
|
||||
// known behaviour: `useCreateAlerts` opens the alerts editor in a
|
||||
// new tab via `window.open(...)` — the current page's URL does not
|
||||
// change. Wait for the new browser tab on the context, not the
|
||||
// existing page.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
createAlerts.click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/alerts\/new/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// First navigate normally and capture the panel's widgetId from the
|
||||
// View action's URL transition — we cannot hard-code a uuid.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
const expandedUrl = page.url();
|
||||
await page
|
||||
.getByRole('dialog', { name: TIME_SERIES_PANEL })
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
// Now hard-navigate to the captured deep-link in a fresh page state.
|
||||
await page.goto(expandedUrl);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-12 Table panel search filters rows in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
|
||||
await expect(tableTitle).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
await container.getByTestId('widget-header-search').click();
|
||||
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so the
|
||||
// table body may be empty. The contract this TC guards is "typing in
|
||||
// the search updates the input value live and does not throw" — a
|
||||
// rendered row count check only fires when telemetry happens to seed
|
||||
// rows. We log no console errors during the search keystrokes either.
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await searchInput.fill('foo');
|
||||
await expect(searchInput).toHaveValue('foo');
|
||||
await searchInput.fill('');
|
||||
await expect(searchInput).toHaveValue('');
|
||||
await searchInput.fill('bar-baz');
|
||||
await expect(searchInput).toHaveValue('bar-baz');
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const chartId = await createChartDataDashboardViaApi(page);
|
||||
seedIds.add(chartId);
|
||||
|
||||
await page.goto(`/dashboard/${chartId}`);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-chart-data-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const panel = page
|
||||
.getByText('E2E Metric RPS', { exact: true })
|
||||
.first()
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.locator('canvas').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const dimensions = await panel
|
||||
.locator('canvas')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const c = el as HTMLCanvasElement;
|
||||
return { w: c.width, h: c.height };
|
||||
});
|
||||
expect(dimensions.w).toBeGreaterThan(0);
|
||||
expect(dimensions.h).toBeGreaterThan(0);
|
||||
|
||||
// Empty-state must NOT render — proves the golden seed landed and
|
||||
// the panel query found rows.
|
||||
await expect(panel.getByText(/no data/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Same panel
|
||||
// clone-then-delete flake family as TC-07/TC-08/TC-09 above — the
|
||||
// Save dialog and / or the delete confirmation intermittently fail
|
||||
// on CI's slower scheduler.
|
||||
test.skip('TC-14 Delete only removes the targeted panel — siblings remain', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// "DB Calls RPS" is a stable sibling we check survives the round-trip.
|
||||
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await sibling.scrollIntoViewIfNeeded();
|
||||
await expect(sibling).toBeVisible();
|
||||
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone first so the test is read-only at the seed level.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await clickNewWidgetSave(page);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone (last in DOM order).
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Delete' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
|
||||
// Originals + siblings still present.
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
await expect(sibling).toBeVisible();
|
||||
});
|
||||
});
|
||||
146
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
146
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Scope: dashboard-side seams only —
|
||||
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
|
||||
// the app supports (the dashboard's responsibility).
|
||||
// 2. A panel created from the dialog actually lands on the canvas and
|
||||
// survives a hard reload (the dashboard's persistence contract).
|
||||
//
|
||||
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
|
||||
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
|
||||
// spec — do NOT add those here.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
|
||||
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
|
||||
seedIds.add(id);
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// Empty dashboards render an onboarding canvas with a duplicate
|
||||
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const tile of [
|
||||
'panel-type-graph',
|
||||
'panel-type-value',
|
||||
'panel-type-table',
|
||||
'panel-type-list',
|
||||
'panel-type-bar',
|
||||
'panel-type-pie',
|
||||
'panel-type-histogram',
|
||||
]) {
|
||||
await expect(dialog.getByTestId(tile)).toBeVisible();
|
||||
}
|
||||
|
||||
// Dialog dismisses via the Close (×) button — confirms the user can
|
||||
// back out without entering the editor (no /new navigation happens
|
||||
// until a tile is picked).
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
});
|
||||
|
||||
// TODO(e2e): re-enable once CI consistently passes. Same flake family
|
||||
// as `21-panel-actions.spec.ts` TC-07 — Save dialog intermittently
|
||||
// fails to appear on CI after `new-widget-save` despite the
|
||||
// `toBeEnabled` gate and `dispatchEvent('click')`. Passes locally
|
||||
// (incl. `STRESS=1 CI=1`). See CI-HARDENING.md.
|
||||
test.skip('TC-02 saving a new panel persists it on the canvas across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
|
||||
seedIds.add(id);
|
||||
const panelName = `e2e-panel-${ts}`;
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'New Panel' })
|
||||
.getByTestId('panel-type-graph')
|
||||
.click();
|
||||
|
||||
// We're now on the editor; minimal interaction — set the name and save.
|
||||
// Anything else (queries, panel-type changes, units) is editor-internal
|
||||
// and belongs in a panel-editor spec.
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
|
||||
// a disabled variant when `isSaveDisabled` is true and an enabled
|
||||
// variant when it is false (see container/NewWidget/index.tsx). Under
|
||||
// CI load the editor mounts with the disabled variant first; clicking
|
||||
// before `toBeEnabled` resolves means the click hits the disabled
|
||||
// button and the Save dialog never opens.
|
||||
const saveBtn = page.getByTestId('new-widget-save');
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
// `dispatchEvent('click')` — under CI viewport the editor's right-header
|
||||
// can be partially covered by the sidenav's secondary nav panel. Bypass
|
||||
// hit testing via a synthetic click. Also wait for the dialog before
|
||||
// registering the PUT listener so we capture the save mutation rather
|
||||
// than any unrelated background request.
|
||||
await saveBtn.dispatchEvent('click');
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
const savePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
const putResp = await savePut;
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
// The editor navigates back to the dashboard inside the PUT onSuccess
|
||||
// handler — wait for the URL to update before asserting on the canvas.
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
// Back on the dashboard — the new panel must render with the typed name.
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Persistence — hard reload, panel still there.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
79
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
79
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// This file's scope is intentionally narrow: prove that the detail page's
|
||||
// "Edit panel" entry-point lands the user in the panel editor at
|
||||
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
|
||||
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
|
||||
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
|
||||
// panel-editor spec — keep this file as the dashboard-side seam only.
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
|
||||
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// "DB Calls RPS" is the only single-instance panel name in the APM
|
||||
// Metrics fixture (other titles like "Latency" repeat across sections),
|
||||
// so it round-trips uniquely without `.first()` gymnastics.
|
||||
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await panelTitle.scrollIntoViewIfNeeded();
|
||||
|
||||
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
|
||||
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
|
||||
// rule on `.widget-graph-component-container:hover .options-action`).
|
||||
const container = panelTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await container.hover();
|
||||
|
||||
const options = container.getByTestId('widget-header-options');
|
||||
// The ⋮ is a `@signozhq/ui` `DropdownMenuSimple` (Radix-based); it opens
|
||||
// on click, not hover. The container hover above only reveals the icon
|
||||
// (it's `visibility: hidden` until then) — the click toggles the menu.
|
||||
await options.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
});
|
||||
});
|
||||
235
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
235
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openTimePicker(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('textbox', { name: /Last \d+/ })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Time Range', () => {
|
||||
test('TC-01 selecting a preset updates the textbox label and URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 hour' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1h/);
|
||||
// Without seeded telemetry the backend may return 4xx for query_range
|
||||
// (panels render "No Data" — a known harness limitation, not a test
|
||||
// bug). Cancelled in-flight responses also surface here as non-ok.
|
||||
// Only 5xx is a real failure; the URL + textbox label assertions
|
||||
// above already prove the preset click took effect.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-02 switching presets twice updates the label both times', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 6 hours' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=6h/);
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Last 1 day' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1d/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=6h/);
|
||||
});
|
||||
|
||||
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Custom Date Range' }).click();
|
||||
|
||||
const prevMonth = page.getByRole('button', {
|
||||
name: 'Go to the Previous Month',
|
||||
});
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await prevMonth.click();
|
||||
}
|
||||
|
||||
// Calendar day buttons have accessible names like "Saturday, March
|
||||
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
|
||||
// + month + year). Pick a known day by its long-form name regex
|
||||
// against the gridcell — `\b14th\b` is unambiguous and avoids
|
||||
// matching siblings like "14" inside "2014".
|
||||
await page
|
||||
.getByRole('gridcell', { name: /\b14th\b/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/startTime=\d+/);
|
||||
await expect(page).toHaveURL(/endTime=\d+/);
|
||||
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
|
||||
// failure. Apply triggered the refetch, which is what we verify.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-04 timezone change updates the toolbar timezone label', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Change Timezone' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Search timezones...' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Coordinated Universal Time —/ })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 refresh-interval popup contents', async ({ authedPage: page }) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Set auto refresh' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await expect(autoRefresh).toBeVisible();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
|
||||
// Labels match the live build (no `15 minutes` / `12 hours` — the
|
||||
// test plan's enumeration was approximate).
|
||||
for (const label of [
|
||||
'5 seconds',
|
||||
'10 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
'30 minutes',
|
||||
'1 hour',
|
||||
'2 hours',
|
||||
'1 day',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-06 toggling auto-refresh on then changing the interval', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Set auto refresh' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: '1 minute', exact: true }).click();
|
||||
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-07 manual sync triggers a query_range refetch', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
|
||||
await page.locator('.refresh-btn button').click();
|
||||
const response = await refetch;
|
||||
// 4xx is expected without seeded telemetry; only 5xx is a failure.
|
||||
// The sync click successfully triggering a query_range fetch is the
|
||||
// behaviour under test.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
483
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
483
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
import variablesTemplate from '../../../testdata/variables-dashboard.json';
|
||||
|
||||
// Variables that depend on backend resolution against seeded telemetry the
|
||||
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
|
||||
// does not block on values that can never appear.
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let varDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
varDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
'detail-variables-suite',
|
||||
);
|
||||
seedIds.add(varDashboardId);
|
||||
// Per the framework contract: every variable with a default has its
|
||||
// `selectedValue` set in the seed JSON; backend-resolved variables
|
||||
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
|
||||
// list them in `skipNames`. Tests must not race ahead of seed
|
||||
// materialisation — this gate ensures the persisted dashboard is in
|
||||
// a known state before any test runs.
|
||||
await awaitVariablesResolved(page, varDashboardId, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function variablesQueryParam(state: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
async function gotoVariablesDashboard(
|
||||
page: Page,
|
||||
urlState?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = urlState
|
||||
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
|
||||
: `/dashboard/${varDashboardId}`;
|
||||
await page.goto(url);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Variables', () => {
|
||||
test('TC-01 variables bar renders all four types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
for (const name of [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
]) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Textbox variables expose their current value via `value` and `title`
|
||||
// attributes (the antd Input has no accessible name matching the value),
|
||||
// so we match on input[value="..."] rather than getByRole+name.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
await expect(page.locator('input[value="frontend"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('variable-select')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// $cu_single (nth(0)) — single-select Custom with three static
|
||||
// options. Driving Custom rather than Query keeps the test
|
||||
// deterministic regardless of seeded telemetry.
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*mq-kafka/);
|
||||
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-03 multi-select renders chips and URL encodes array', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// URL state seeds adservice + cartservice as initial selection; this also
|
||||
// guarantees the URL contains the encoded array so we can assert on it
|
||||
// without relying on the seeded server-side selection rendering identically
|
||||
// across reloads.
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/adservice/);
|
||||
await expect(page).toHaveURL(/cartservice/);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
|
||||
|
||||
// Removing a chip on a multi-select expands the dropdown; URL state
|
||||
// only commits when the dropdown closes (onDropdownVisibleChange =>
|
||||
// false). The CustomMultiSelect swallows Escape, so click outside the
|
||||
// dropdown to dismiss it.
|
||||
await page.locator('img[alt="dashboard-img"]').click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
await expect(page).not.toHaveURL(/adservice/);
|
||||
});
|
||||
|
||||
test('TC-05 ALL option on a Custom variable', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
|
||||
|
||||
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
|
||||
// so the dropdown exposes an "ALL" toggle alongside the static options.
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', {
|
||||
hasText: 'otel-demo',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'ALL' }).click();
|
||||
|
||||
// When ALL is selected, the multi-select renders an "ALL" badge in a
|
||||
// custom container (not the standard .ant-select-selection-item), so
|
||||
// match on the option's checked state inside the dropdown listbox
|
||||
// rather than on the closed-state chip.
|
||||
await expect(page.getByRole('option', { name: 'ALL' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
});
|
||||
|
||||
test('TC-06 textbox variable update propagates to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Locate by the testid wrapping a stable id, since `input[value="..."]`
|
||||
// becomes stale the moment we fill('') the field.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
const tb = page.getByPlaceholder('Enter value').first();
|
||||
await tb.click();
|
||||
await tb.fill('');
|
||||
await tb.fill('production');
|
||||
await tb.press('Enter');
|
||||
|
||||
await expect(page.locator('input[value="production"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*production/);
|
||||
});
|
||||
|
||||
test('TC-07 cascading: child variable listbox opens after parent change', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
|
||||
|
||||
// q_service (nth(4)) is cascaded from q_env (nth(3)).
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
|
||||
// known behaviour: the child's option list requires seeded telemetry —
|
||||
// the bootstrap stack has none, so we only assert that the listbox
|
||||
// renders without crashing rather than checking specific options.
|
||||
await expect(page.getByRole('listbox').first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
});
|
||||
|
||||
test('TC-08 URL deep-link restores variable state on hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
|
||||
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
|
||||
|
||||
// Seed defaults to ALL — open, pick a specific value, assert URL.
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/mq-kafka/);
|
||||
|
||||
// Re-open, switch back to ALL — URL must update again.
|
||||
await dropdown.click();
|
||||
const allOption = page.getByRole('option', { name: 'ALL' });
|
||||
await allOption.click();
|
||||
await expect(allOption).toHaveAttribute('aria-selected', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
|
||||
await expect(page).not.toHaveURL(/mq-kafka/);
|
||||
});
|
||||
|
||||
test('TC-10 two variables changed in sequence both encode in URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// cu_single — pick `production`.
|
||||
const single = page.getByTestId('variable-select').nth(0);
|
||||
await single.click();
|
||||
await page.getByRole('option', { name: 'production' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
|
||||
// q_service — open the multi-select, dismiss without picking. The URL
|
||||
// should still contain the previous selection.
|
||||
const cuServices = page.getByTestId('variable-select').nth(2);
|
||||
await cuServices.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
await expect(page).toHaveURL(/cu_single/);
|
||||
});
|
||||
|
||||
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
const stateUrl = page.url();
|
||||
|
||||
// Leave to the list, come back via browser back — URL is restored.
|
||||
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
|
||||
// events at the breadcrumb's center, defeating even `force: true`.
|
||||
// Dispatching the click directly on the DOM node bypasses hit testing.
|
||||
await page
|
||||
.getByRole('button', { name: 'Dashboard /' })
|
||||
.dispatchEvent('click');
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(stateUrl);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
|
||||
// They are intentional gaps, not bugs — when the feature ships or the seed
|
||||
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
|
||||
// implement.
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
|
||||
// Requires extending variables-dashboard.json with a Custom variable
|
||||
// that has no `selectedValue` and no `allSelected`. The UI should
|
||||
// render the dropdown empty/"Select value" until a user picks.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
|
||||
// Requires extending variables-dashboard.json with a Query variable
|
||||
// that ships with `selectedValue` already populated — the UI should
|
||||
// trust the seed and not block on a query.
|
||||
});
|
||||
|
||||
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// q_service is the only multi-select Query in the seed (nth(4) in
|
||||
// the dropdown order). Without telemetry the option list is empty —
|
||||
// assert the empty-state explicitly.
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
const listbox = page.getByRole('listbox').first();
|
||||
await expect(listbox).toBeVisible();
|
||||
await expect(listbox.getByRole('option')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
|
||||
// over the `metrics` source. The bootstrap OTel collector ingests
|
||||
// the golden dataset which tags every resource with
|
||||
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
|
||||
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
|
||||
// naturally by the collector's signozclickhousemetrics exporter, and
|
||||
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
|
||||
// surfaces the values so the Dynamic variable auto-resolves.
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// d_namespace is the 6th dropdown variable in DOM order. The
|
||||
// closed-state of the combobox renders the auto-resolved value
|
||||
// inline next to the variable name. Match any of the 8 seeded
|
||||
// namespaces — ordering depends on the backend sort, so we accept
|
||||
// whichever it returns first.
|
||||
const dynamic = page.getByTestId('variable-select').nth(5);
|
||||
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
|
||||
// $service.name and $deployment.environment are referenced by APM
|
||||
// panel queries. Asserting that a variable change triggers a
|
||||
// query_range refetch with the new substitution requires either
|
||||
// seeded telemetry or a network-request listener that confirms the
|
||||
// outbound query body contains the new value. Defer until the
|
||||
// chart-data assertion path is in place.
|
||||
});
|
||||
|
||||
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Expected order matches the `order` field in variables-dashboard.json.
|
||||
const expected = [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
];
|
||||
const allText = await page.locator('text=/^\\$\\w+$/').allInnerTexts();
|
||||
const actual = allText.filter((t) => /^\$\w+$/.test(t));
|
||||
expect(actual.slice(0, expected.length)).toEqual(expected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
|
||||
// The Configure → Variables tab supports drag handles. After a
|
||||
// reorder, the persisted `order` fields should update and the
|
||||
// variables bar should re-render in the new order.
|
||||
});
|
||||
|
||||
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// `tb_service` (textbox, no dependents) — easiest to remove cleanly.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(0);
|
||||
|
||||
// Restore the persisted variable so subsequent serial-mode tests still pass.
|
||||
const token = await authToken(page);
|
||||
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
|
||||
data: { ...variablesTemplate, title: 'detail-variables-suite' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
332
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
332
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openEditMode(page: Page): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
}
|
||||
|
||||
async function closeEditModeIfOpen(page: Page): Promise<void> {
|
||||
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
|
||||
if (await lockBtn.isVisible().catch(() => false)) {
|
||||
await lockBtn.click({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Edit Mode', () => {
|
||||
test('TC-01 edit-mode popup contains all six action buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-popup');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copy as JSON' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-02 Lock Dashboard exits edit mode', async ({ authedPage: page }) => {
|
||||
const id = await seed(page, 'edit-mode-lock');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `original-${ts}`;
|
||||
const renamed = `Renamed-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
await expect(renameDialog).toBeVisible();
|
||||
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const restoreDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const restoreInput = restoreDialog.getByTestId('dashboard-name');
|
||||
await restoreInput.fill('');
|
||||
await restoreInput.fill(original);
|
||||
await restoreDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 cancel rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cancel-rename-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill('Should Not Be Saved');
|
||||
|
||||
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-05 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await seed(page, `edit-mode-section-${ts}`);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
const sectionName = `e2e-section-${ts}`;
|
||||
await sectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await sectionDialog.getByRole('button', { name: 'Create Section' }).click();
|
||||
|
||||
const sectionTitle = page
|
||||
.locator('.section-title')
|
||||
.filter({ hasText: sectionName });
|
||||
await expect(sectionTitle).toBeVisible();
|
||||
|
||||
// Cleanup — remove the section. The ellipsis trigger sits on the
|
||||
// `.row-panel` container alongside the section title; the popover it
|
||||
// opens has rootClassName="row-settings" and renders at body level.
|
||||
const sectionRow = sectionTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class, "row-panel")]',
|
||||
);
|
||||
await sectionRow.hover();
|
||||
await sectionRow.locator('.settings-icon').click();
|
||||
const rowSettingsPopover = page.locator('.row-settings');
|
||||
await expect(rowSettingsPopover).toBeVisible();
|
||||
await rowSettingsPopover
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(sectionTitle).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-06 Export JSON triggers a .json download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-export');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export JSON' }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const ts = Date.now();
|
||||
const title = `edit-mode-copy-${ts}`;
|
||||
const id = await seed(page, title);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Copy as JSON' }).click();
|
||||
|
||||
const clipboardText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const parsed = JSON.parse(clipboardText) as { title?: string };
|
||||
expect(parsed.title ?? '').toContain(title);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// known behaviour: headless Chromium does not honour the Fullscreen API,
|
||||
// so we cannot assert `document.fullscreenElement`. Verifying that the
|
||||
// click is benign (breadcrumb still rendered) is the strongest cross-env
|
||||
// check available.
|
||||
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-fullscreen');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Full screen' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock-roundtrip');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Lock the dashboard.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Re-opening the popup after a lock shows the Unlock label instead of
|
||||
// Lock. The button label flips based on `isDashboardLocked`.
|
||||
await openEditMode(page);
|
||||
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
|
||||
await expect(unlockBtn).toBeVisible();
|
||||
await unlockBtn.click();
|
||||
|
||||
// After unlock, the popup should re-expose the original action buttons.
|
||||
await openEditMode(page);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-10 rename persists across hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `rename-persist-${ts}`;
|
||||
const renamed = `Renamed-Persist-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Hard reload — name must still be the renamed one.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveTitle(new RegExp(renamed));
|
||||
});
|
||||
});
|
||||
687
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
687
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
|
||||
// so this file still compiles while it is missing — tests that need it skip
|
||||
// at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dashboardsHelpers = require('../../../helpers/dashboards') as {
|
||||
createVariablesDashboardViaApi?: (
|
||||
page: Page,
|
||||
title: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
const hasVariablesHelper =
|
||||
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedVariablesDashboard(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
|
||||
throw new Error('createVariablesDashboardViaApi helper is not available');
|
||||
}
|
||||
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
// Wait for the seeded dashboard's variables to fully resolve before any
|
||||
// caller test acts on them. Variables with defaults already have
|
||||
// `selectedValue` set; Query/Dynamic variables can't resolve without
|
||||
// telemetry and are skipped.
|
||||
await awaitVariablesResolved(page, id, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openConfigureDrawer(page: Page) {
|
||||
// An empty dashboard renders an onboarding canvas with a duplicate
|
||||
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
|
||||
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
|
||||
// strict-mode collision.
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function deleteVariableByName(page: Page, varName: string) {
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
// Walk up to the surrounding row container to scope the delete-button
|
||||
// search; `.variable-item` (or the variable row container) wraps the
|
||||
// hover-revealed delete button.
|
||||
await nameCell
|
||||
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-drawer-chrome');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Overview' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 update name, description, and tag — persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cfg-overview-save-${ts}`;
|
||||
const updated = `Configured-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await nameInput.click();
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(updated);
|
||||
|
||||
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
|
||||
|
||||
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.fill(`e2e-tag-${ts}`);
|
||||
await tagInput.press('Enter');
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save' });
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
const [putResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
),
|
||||
saveBtn.click({ force: true }),
|
||||
]);
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${updated}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const original = 'cfg-overview-discard';
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
|
||||
await nameInput.fill('Temp Modified Name');
|
||||
const discard = dialog.getByRole('button', { name: 'Discard' });
|
||||
await expect(discard).toBeVisible();
|
||||
await discard.click();
|
||||
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-04 Variables tab lists existing variables', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(tabpanel).toBeVisible();
|
||||
|
||||
for (const varName of [
|
||||
'tb_env',
|
||||
'tb_service',
|
||||
'cu_env_all',
|
||||
'cu_services',
|
||||
'q_env',
|
||||
'q_service',
|
||||
'd_namespace',
|
||||
]) {
|
||||
// Variable rows render as plain text inside the Variables tab
|
||||
// (not a true Antd `Table` with role="row"). Locate via text.
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `tb_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await saveBtn.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(`$${varName}`)).toBeVisible();
|
||||
|
||||
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
|
||||
await newTextbox.fill('test-value');
|
||||
await newTextbox.press('Enter');
|
||||
await expect(page).toHaveURL(/test-value/);
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
test('TC-06 add a Custom variable — appears in the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `custom_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
|
||||
// not implemented. Dynamic variables source from the SigNoz attribute
|
||||
// index — the bootstrap stack ingests no telemetry, so the field selector
|
||||
// renders an empty option list and Save Variable can never be enabled.
|
||||
// Re-add once the bootstrap seeds telemetry attributes.
|
||||
|
||||
test('TC-08 selecting Query type renders the query editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `query_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-09 Save Variable disabled when name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-variables-empty-name');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
const nameField = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameField).toHaveValue('');
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-10 Publish tab shows private message and Publish button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-publish');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Publish' }).click();
|
||||
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('checkbox', { name: 'Enable time range' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Dashboard variables won't work in public dashboards"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Publish dashboard' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// `test.skip` placeholders for behaviours not yet covered. Replace with
|
||||
// `test` and implement when the corresponding feature ships or the seed
|
||||
// gains the necessary state.
|
||||
|
||||
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
// Hover the row to reveal the edit button (Pylon overlay can intercept,
|
||||
// so dispatchEvent fires the click directly on the React onClick).
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Editor form mounts; rename and save.
|
||||
const renamed = `tb_env_renamed_${Date.now()}`;
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill(renamed);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Variables bar reflects the rename; the original label is gone.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-type');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Change type from Custom to Query and verify the form swaps to the
|
||||
// Query editor (Monaco SQL editor mounts where the comma-separated
|
||||
// values input used to live).
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
|
||||
// arrive under parallel-worker CI load, far longer than the default 5 s
|
||||
// locator timeout. 20 s is comfortable headroom without masking real
|
||||
// regressions.
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The previous Custom-specific fields must no longer be visible.
|
||||
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Discard rather than save — saving without filling the new query
|
||||
// would leave a half-configured Query variable. The contract this TC
|
||||
// guards is "type switching swaps the form correctly", which the
|
||||
// assertions above already prove.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-default');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Update the default textbox value. The Default Value input is the
|
||||
// second/third field (Name first); locate it via its placeholder.
|
||||
const defaultInput = dialog
|
||||
.getByPlaceholder(/Enter default value|Default value/i)
|
||||
.first();
|
||||
await defaultInput.fill('new-default');
|
||||
|
||||
// PUT confirms the variable persisted server-side before we close +
|
||||
// reload. Without this wait the reload races the save and the old
|
||||
// "otel-demo" default renders, producing the observed flake.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
await putResponse;
|
||||
|
||||
// Reload — the new default renders without URL state because it's
|
||||
// now the persisted seed value.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('input[value="new-default"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 delete variable — removed from variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Reuse the existing helper and assert the variables bar reflects
|
||||
// the deletion — `deleteVariableByName` covers the Configure-side
|
||||
// removal; the bar update is the new contract this TC adds.
|
||||
await deleteVariableByName(page, 'tb_env');
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
// Sibling textbox is unaffected.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
// Save Variable should refuse to enable while the name collides with
|
||||
// an existing variable. Assert the button stays disabled, OR a
|
||||
// validation message surfaces — UI may pick either signal.
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
|
||||
|
||||
// Either Save is disabled, or an explicit error is shown — both are
|
||||
// valid contracts. `Promise.race` between the two assertions tolerates
|
||||
// whichever the UI provides.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const err = await errorMsg.isVisible().catch(() => false);
|
||||
return disabled || err;
|
||||
})
|
||||
.toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
|
||||
// Names containing spaces, $-prefix, dots, etc. should be rejected
|
||||
// by the validator. Confirm Save Variable stays disabled with an
|
||||
// inline error message.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
|
||||
// The Variables tab supports drag handles. After a reorder, the
|
||||
// persisted `data.variables[*].order` reflects the new sequence and
|
||||
// the variables bar re-renders accordingly.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
|
||||
// Dynamic-variable resolution itself is covered by
|
||||
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
|
||||
// namespace → URL state updates). What this TC adds is the Configure
|
||||
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
|
||||
// uses a combobox whose stable locator hasn't been pinned in this
|
||||
// suite yet — leave skipped pending a snapshot pass.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
|
||||
// `description` field on each variable should be surfaced in the
|
||||
// variables bar tooltip and in the Variables tab's row.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
|
||||
// For a Query variable mid-resolution, Save Variable should be
|
||||
// disabled until the query returns options. Otherwise we'd save
|
||||
// a variable with an empty option list.
|
||||
});
|
||||
|
||||
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Open the editor for tb_env and dirty the Name field.
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill('SHOULD_NOT_PERSIST');
|
||||
|
||||
// Discard, then re-open the same row. The Name must still be the
|
||||
// original — abandoned edits never reach the persisted JSON.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Reopen Configure → tb_env still has the original name.
|
||||
const dialog2 = await openConfigureDrawer(page);
|
||||
await dialog2.getByRole('tab', { name: 'Variables' }).click();
|
||||
await expect(
|
||||
dialog2
|
||||
.getByRole('tabpanel', { name: 'Variables' })
|
||||
.getByText('tb_env', { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
243
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
243
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const VARIABLES_TITLE = 'detail-edge-cases-variables';
|
||||
let apmDashboardId = '';
|
||||
let variablesDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
variablesDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
VARIABLES_TITLE,
|
||||
);
|
||||
seedIds.add(variablesDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function encodeVariables(payload: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
async function gotoDetail(page: Page, id: string, query = ''): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}${query}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Edge Cases', () => {
|
||||
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(
|
||||
page,
|
||||
apmDashboardId,
|
||||
'?startTime=1672531200000&endTime=1672531260000',
|
||||
);
|
||||
|
||||
// The dashboard chrome must render with the far-past range applied:
|
||||
// breadcrumb resolves the dashboard title, panel headers render, and the
|
||||
// time-range textbox reflects the URL.
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// known behaviour: with no variable values resolvable in the far-past
|
||||
// window, APM panels stay in a waiting-on-variable state and never
|
||||
// render the uplot "No Data" overlay. The contract this TC really
|
||||
// guards is that the page does not throw — assert no client-side
|
||||
// pageerror was raised.
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
|
||||
// The chrome (sidebar logo) must always render, regardless of whether
|
||||
// the app redirects to /dashboard or shows an in-place error shell.
|
||||
// The bogus-id breadcrumb must never resolve.
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon nonexistent-id-99999/,
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 variable URL deep-link survives hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
|
||||
await gotoDetail(page, variablesDashboardId, deepLink);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
|
||||
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
|
||||
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
|
||||
const qEnv = page.getByTestId('variable-select').nth(3);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
// known behaviour: the app may either reject a swapped range
|
||||
// client-side or render error states per-panel — either way, the
|
||||
// dashboard chrome and at least one panel header must still render.
|
||||
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const longName = `LongName-${'x'.repeat(190)}`;
|
||||
const id = await createDashboardViaApi(page, longName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
// The toolbar must still render — long titles cannot push the toolbar
|
||||
// off-screen or unmount it. Scope to `.right-section` because empty
|
||||
// dashboards render an onboarding canvas with duplicate testids.
|
||||
const toolbar = page.locator('.dashboard-details .right-section');
|
||||
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
|
||||
const id = await createDashboardViaApi(page, trickyName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
// The full title must round-trip through the breadcrumb without HTML
|
||||
// entity mangling (`&`, `"` are bugs we'd want to catch).
|
||||
await expect(
|
||||
page.getByText(trickyName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
// document.title is set from the dashboard name — confirm it is intact.
|
||||
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
promqlQuery,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
textPanelWithQueryDashboard,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: how the editor explains a disabled kind, and the backend enforcing the
|
||||
// same panelKind × queryType matrix.
|
||||
|
||||
test.describe('Dashboards — editor capabilities', () => {
|
||||
test('TC-01 a disabled option explains itself in a tooltip', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.openTypeSwitcher();
|
||||
await expect(editor.typeOption(PanelKind.List)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
await editor.typeOption(PanelKind.List).hover();
|
||||
|
||||
// The wording is the contract users read.
|
||||
await expect(
|
||||
panels.tooltip.text("List doesn't support metrics data"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 the backend rejects a combination the editor disables', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
// Enforced server-side too, so an invalid pairing can never be persisted.
|
||||
// Pins the layers together: if capabilities.ts and allowedQueryKinds
|
||||
// drift, either the switcher contract or this fails.
|
||||
await expect(
|
||||
dashboards.seed(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.PieChart,
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
),
|
||||
// Quotes in the message are JSON-escaped, so match around them.
|
||||
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
|
||||
});
|
||||
|
||||
test('TC-03 the backend rejects a Text panel that carries a query', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
await expect(dashboards.seed(textPanelWithQueryDashboard())).rejects.toThrow(
|
||||
/renders without a query and must have queries: \[\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-config';
|
||||
|
||||
// Scope: the Context Links dialog and that a saved link reaches
|
||||
// `panel.spec.links` — where it surfaces as a `drilldown-context-link`
|
||||
// (covered in panels/57-drilldown).
|
||||
|
||||
const ONE_LINK = singlePanelDashboard({
|
||||
links: [{ name: 'Original', url: 'https://example.com/one' }],
|
||||
});
|
||||
|
||||
test.describe('Dashboards — editor context links', () => {
|
||||
test('TC-01 the section is offered and opens an empty dialog', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
await expect(links.dialog()).toBeVisible();
|
||||
await expect(links.label()).toHaveValue('');
|
||||
await expect(links.url()).toHaveValue('');
|
||||
});
|
||||
|
||||
test('TC-02 Save stays disabled until the link is valid', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
// A link with no URL would be a dead menu entry.
|
||||
await expect(links.save()).toBeDisabled();
|
||||
await links.label().fill('Runbook');
|
||||
await expect(links.save()).toBeDisabled();
|
||||
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await expect(links.save()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-03 a saved link persists into panel.spec.links', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Runbook');
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
|
||||
await expect(links.item(0)).toBeVisible();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// "Label" persists as `name` (Perses link model).
|
||||
expect(saved[0]).toMatchObject({
|
||||
name: 'Runbook',
|
||||
url: 'https://example.com/runbook',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-04 Cancel discards the dialog without adding a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Throwaway');
|
||||
await links.url().fill('https://example.com');
|
||||
await links.blurFields();
|
||||
await links.cancel().click();
|
||||
|
||||
await expect(links.dialog()).toHaveCount(0);
|
||||
await expect(links.item(0)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-05 an existing link can be edited', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.edit(0).click();
|
||||
await links.label().fill('Renamed');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links[0]).toMatchObject({
|
||||
name: 'Renamed',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-06 a link can be removed', async ({ dashboards, editor, config }) => {
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await config.contextLinks.remove(0).click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-07 URL parameters can be added to a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('With params');
|
||||
await links.url().fill('https://example.com/search');
|
||||
|
||||
await links.blurFields();
|
||||
await links.addParam().click();
|
||||
await links.paramKey(0).fill('service');
|
||||
await links.paramValue(0).fill('adservice');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// Params fold into the persisted URL.
|
||||
expect(new URL(saved[0].url).searchParams.get('service')).toBe('adservice');
|
||||
});
|
||||
|
||||
test('TC-08 the header quick-add opens the dialog from a collapsed section', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// One click must expand AND add (pendingAction hop).
|
||||
await editor.section.collapse(Section.contextLinks);
|
||||
await config.contextLinks.headerAdd().click();
|
||||
|
||||
await expect(editor.section.toggle(Section.contextLinks)).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
await expect(config.contextLinks.dialog()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import {
|
||||
compactDashboard,
|
||||
emptyDashboard,
|
||||
expect,
|
||||
PANEL_TYPE_TILES,
|
||||
PanelKind,
|
||||
PATH,
|
||||
singlePanelDashboard,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: creating a panel — the New panel drawer, the route it hands off to,
|
||||
// and the JSON-Patch a save emits.
|
||||
//
|
||||
// The subtlety: a tile click only selects; the confirm button carries the
|
||||
// target section, and the editor route carries it on as `layoutIndex`.
|
||||
|
||||
test.describe('Dashboards — panel picker', () => {
|
||||
test('TC-01 with one section a tile click selects and Add panel creates', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
// No section to choose, so the confirm names none.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add panel');
|
||||
|
||||
await newPanel.pick(PanelKind.Table);
|
||||
await expect(page).toHaveURL(PATH.onDashboard(id));
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Table);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 with several sections the confirm names its target section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
// Every section is titled, so the toolbar trigger defaults to a new root.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Dashboard (root)');
|
||||
await expect(newPanel.tile(PanelKind.TimeSeries)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Number);
|
||||
expect(dashboards.param('layoutIndex')).toBe('root');
|
||||
});
|
||||
|
||||
test('TC-03 the chosen section becomes the new panel layoutIndex', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.Number);
|
||||
await newPanel.chooseSection('1');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Tabular');
|
||||
await newPanel.confirm();
|
||||
|
||||
expect(dashboards.param('layoutIndex')).toBe('1');
|
||||
});
|
||||
|
||||
test('TC-04 creating from an empty section targets that section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
const dashboard = compactDashboard();
|
||||
|
||||
dashboard.spec.layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: 'Empty' }, items: [] },
|
||||
});
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
|
||||
await newPanel.sectionAddPanel().scrollIntoViewIfNeeded();
|
||||
await newPanel.sectionAddPanel().click();
|
||||
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Empty');
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
expect(dashboards.param('layoutIndex')).toBe('2');
|
||||
});
|
||||
|
||||
test('TC-05 saving a new panel emits add-panel and add-layout-item ops', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const patches = editor.capturePatches();
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List: its seeded query passes validation (see TC-06).
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.title().fill('Created from the modal');
|
||||
await editor.save();
|
||||
|
||||
// Targeted adds for panel AND grid item — a replace would clobber edits.
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
const ops = patches[patches.length - 1];
|
||||
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
|
||||
expect(panelAdd?.op).toBe('add');
|
||||
expect(
|
||||
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the created panel lands on the dashboard and persists', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const before = await dashboards.get(id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List is the one kind whose seeded query saves as-is (logs `count()`);
|
||||
// metrics kinds are rejected until a metric is chosen — see TC-07.
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await editor.title().fill('Fresh list panel');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: 'Fresh list panel' }),
|
||||
).toBeVisible();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-07 saving a metrics panel with no metric chosen is rejected', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
// Metrics kinds seed an empty aggregation, so the editor must stay open and
|
||||
// surface the reason rather than dropping the panel.
|
||||
const { ok, body } = await editor.trySave();
|
||||
expect(ok).toBe(false);
|
||||
expect(body).toContain('metric name is required');
|
||||
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 the editor route redirects when panelKind is missing', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
// No kind to seed, so the page bounces back.
|
||||
await dashboards.visit(PATH.editor(id, 'new'));
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(panels.panel(SINGLE_PANEL_ID).root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-09 a new section from the picker is created on save', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.List);
|
||||
await newPanel.newSection('Fresh section');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to new section');
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('newSection')).toBe('Fresh section');
|
||||
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const section = after.spec.layouts.find(
|
||||
(layout) => layout.spec.display?.title === 'Fresh section',
|
||||
);
|
||||
expect(section?.spec.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('TC-10 search narrows the tiles and offers a way back', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
// Matches name, description and group label.
|
||||
await newPanel.search().fill('pie');
|
||||
await expect(newPanel.tiles()).toHaveCount(1);
|
||||
await expect(newPanel.tile(PanelKind.PieChart)).toBeVisible();
|
||||
|
||||
await newPanel.search().fill('no-such-panel');
|
||||
await expect(newPanel.tiles()).toHaveCount(0);
|
||||
await expect(newPanel.emptyState()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 a category chip filters to its group', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
await newPanel.category('Raw records').click();
|
||||
await expect(newPanel.groups()).toHaveText(['Raw records']);
|
||||
await expect(newPanel.tiles()).toHaveCount(
|
||||
PANEL_TYPE_TILES.filter((t) => t.category === 'Raw records').length,
|
||||
);
|
||||
|
||||
await newPanel.category('All').click();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_TYPE_TILES.length);
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
PATH,
|
||||
QueryTab,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the query pane and its commit semantics.
|
||||
//
|
||||
// The rule worth pinning: editing the builder does NOT move the preview (only
|
||||
// Run, or a structural change, commits into the draft) — but Save serialises
|
||||
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
|
||||
// and easy to "fix" into a regression.
|
||||
|
||||
test.describe('Dashboards — panel editor query pane', () => {
|
||||
test('TC-01 Run Query issues a fresh query_range', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.query.run();
|
||||
});
|
||||
|
||||
test('TC-02 the run keyboard shortcut works from inside the builder', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Bound with onKeyDownCapture, so it fires from inside inputs too.
|
||||
await editor.query.runShortcut();
|
||||
});
|
||||
|
||||
test('TC-03 switching query type re-renders the pane and marks the panel dirty', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
|
||||
await editor.query.tab(QueryTab.promql).click();
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
// Structural change: auto-commits without Run.
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 choosing a metric makes a new panel savable', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
// create → configure → save. A new metrics panel seeds an empty
|
||||
// aggregation and is rejected until a metric is picked (picker TC-07).
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.calls);
|
||||
await editor.title().fill('Configured then saved');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const saved = Object.values(after.spec.panels).find(
|
||||
(candidate) => candidate.spec.display.name === 'Configured then saved',
|
||||
);
|
||||
expect(saved).toBeDefined();
|
||||
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
|
||||
});
|
||||
|
||||
test('TC-05 an unrun query edit is still persisted by Save', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// No Run: `buildSaveSpec` serialises the live query, so the edit must
|
||||
// survive — otherwise saving without running loses work.
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencyCount);
|
||||
await editor.save();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await dashboards.get(id);
|
||||
return JSON.stringify(
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
|
||||
).includes(GOLDEN.metrics.latencyCount);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the in-editor query survives a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencySum);
|
||||
await editor.query.run();
|
||||
|
||||
// No forceReset, so the URL query wins and survives a refresh.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.query.metricInput()).toHaveValue(
|
||||
GOLDEN.metrics.latencySum,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 a logs panel runs without needing a metric', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Logs aggregate by expression, so there's no metric to fill.
|
||||
await expect(editor.query.runButton()).toBeVisible();
|
||||
await editor.query.run();
|
||||
await editor.save();
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
EditorText,
|
||||
expect,
|
||||
PATH,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the editor shell — editing, dirty badge, save, discard guard, locking.
|
||||
//
|
||||
// Two counter-intuitive behaviours are pinned so a "cleanup" doesn't change
|
||||
// them: Save is NOT gated on dirty state (TC-03), and only the in-app close
|
||||
// button guards unsaved edits — there is no beforeunload blocker (TC-07).
|
||||
|
||||
test.describe('Dashboards — panel editor shell', () => {
|
||||
test('TC-01 the editor opens on the panel with its saved title', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.heading()).toBeVisible();
|
||||
await expect(editor.title()).toHaveValue('Solo panel');
|
||||
await expect(editor.preview.root()).toBeVisible();
|
||||
await expect(editor.query.builder()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 editing the title marks the editor dirty', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
await editor.title().fill('Renamed panel');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Save stays enabled on a pristine panel', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// Gated on editability, not isDirty.
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
await expect(editor.saveButton()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-04 saving persists the title and returns to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const patches = editor.capturePatches();
|
||||
|
||||
await editor.title().fill('Renamed via editor');
|
||||
await editor.description().fill('Edited in the E2E suite');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Renamed via editor',
|
||||
);
|
||||
|
||||
// A single targeted add at the panel's spec pointer.
|
||||
const ops = patches[patches.length - 1];
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe(`/spec/panels/${SINGLE_PANEL_ID}/spec`);
|
||||
expect(ops[0].op).toBe('add');
|
||||
});
|
||||
|
||||
test('TC-05 closing a pristine editor leaves immediately', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.close();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(editor.discardModal()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 closing a dirty editor asks before discarding', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title().fill('Throwaway edit');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
|
||||
await editor.close({ expectDirty: true, keepEditing: true });
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.title()).toHaveValue('Throwaway edit');
|
||||
|
||||
await editor.close({ expectDirty: true });
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 unsaved edits hold a refresh, but an in-app route change drops them', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
expect(await editor.blocksUnload()).toBe(false);
|
||||
|
||||
await editor.title().fill('Never saved');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
expect(await editor.blocksUnload()).toBe(true);
|
||||
|
||||
// Known behaviour, not endorsed: a refresh or tab close prompts, but there
|
||||
// is no router blocker. If one is added, update this test, don't delete it.
|
||||
await dashboards.visit(PATH.dashboard(id));
|
||||
await expect(editor.discardModal()).toHaveCount(0);
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-08 a locked dashboard disables Save and never PATCHes', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
|
||||
const patches = editor.capturePatches();
|
||||
await dashboards.openEditor(id, SINGLE_PANEL_ID);
|
||||
|
||||
const save = editor.saveButton();
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
await editor.hoverSave();
|
||||
await expect(panels.tooltip.text(EditorText.lockedReason)).toBeVisible();
|
||||
|
||||
// The store short-circuits a locked patch before the network.
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-09 an unknown panel id redirects back to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
await dashboards.visit(PATH.editor(id, 'does-not-exist'));
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(editor.root()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-10 Switch to View Mode hands off to the View modal', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchToView().click();
|
||||
await page.waitForURL(PATH.onDashboardWithParams(id));
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../drivers/panel-editor';
|
||||
|
||||
// Scope: switching kind mid-edit — what the session cache restores, and which
|
||||
// config survives a first-visit transfer. Config only carries when the TARGET
|
||||
// kind declares that control (buildPluginSpec).
|
||||
|
||||
test.describe('Dashboards — panel type switching', () => {
|
||||
test('TC-01 switching kind re-renders the preview', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 switching marks the editor dirty but does not persist', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.Number);
|
||||
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.TimeSeries,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 switching back restores the original kind and its settings from the session cache', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
// Logs, because List rejects metrics; time_series-shaped, because Table
|
||||
// can't read `raw`.
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await editor.setTile('decimals', '3');
|
||||
|
||||
// Not the default, so a freshly built Table would not have it.
|
||||
await editor.switchType(PanelKind.List);
|
||||
await expect(editor.preview.renderer(PanelKind.List)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await expect(editor.tileInput('decimals', '3')).toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-04 the session cache does not survive a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
// The cache is a ref on the mounted editor, so a reload drops it. Reload
|
||||
// alone — a redundant goto afterwards crashed the page on WebKit.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a saved switch persists the new kind', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.BarChart,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 a panel-wide unit fans out into per-column units on Table', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await editor.save();
|
||||
|
||||
// Table has no panel-wide unit, so it fans out to columns — one-way.
|
||||
const after = await dashboards.get(id);
|
||||
const formatting =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
|
||||
expect(formatting?.unit).toBeUndefined();
|
||||
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
|
||||
});
|
||||
|
||||
test('TC-07 custom legend colours are dropped on a first-visit switch', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: {
|
||||
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
|
||||
},
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
// Keyed by series label, which the new kind may not reproduce, so dropped.
|
||||
// Round-trips as null rather than being omitted.
|
||||
const after = await dashboards.get(id);
|
||||
const { customColors } =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
|
||||
expect(customColors ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
test('TC-08 axis bounds survive a switch between kinds that both declare them', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
await editor.control('soft-min').fill('5');
|
||||
await editor.control('soft-max').fill('50');
|
||||
|
||||
// Bar Chart also declares minMax, so the bounds must carry over.
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
|
||||
expect(axes?.softMin).toBe(5);
|
||||
expect(axes?.softMax).toBe(50);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user