Compare commits

...

9 Commits

Author SHA1 Message Date
Abhi Kumar
5d28d0b6a0 test(dashboards): expose the uPlot instance on the chart container
A chart is a canvas, so a test cannot read its series, scales or bands any other way. Both the
e2e suite and the panel stories read it.
2026-10-27 00:11:17 +05:30
Aditya Singh
1e89b49ecf chore(quick-filters): remove the old checkbox filter (#13262)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description
- All quick filter pages use `CheckboxV2` and the fields APIs. This PR
removes the old checkbox filter and the code only it used:
- the old `Checkbox` component and its
`/v3/autocomplete/attribute_values` fetch
- the `useGetQueryKeyValueSuggestions` hook and the deprecated
`getValueSuggestions` client
- `EmptyQuickFilterIcon` and an Infra Monitoring style for the old
checkbox
- `useFieldApis` is now required on `QuickFilters`. Every page already
passes it, so users see no change.
- The deleted checkbox test had the only tests for the auto-open of a
filter with an active clause. These tests now run against
`CheckboxFilterV2`.

#### Issues closed by this PR

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

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

Bump deps to address the reports by Vanta.

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

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

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

#### Issues closed by this PR

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

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

#### Issues closed by this PR

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

---------

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

- The skill walks the end-to-end authz procedure from
`docs/contributing/go/authz.md` — registering kinds/resources, granting
managed-role permissions, wiring routes with `ResourceDef`s, backfilling
tuples, and writing the authz integration tests.
2026-10-26 15:50:33 +00:00
Swapnil Nakade
aaca29d26d feat: adding gcp cloud integration agent mode changes (#13103)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adding gcp cloud integration agent mode support
  - Type changes
  - Module changes
  - Generated openapi specs
- Added a new key ConnectionMethod for GCP provider to differentiate
between Agent and Manual flow
- Added integration tests for agent mode
- Frontend changes will be addressed in different PR

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/keystone-pod/issues/148

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-26 09:39:25 +00:00
ashu8912
2139580419 fix(settings): remove notification channels from settings nav (#13225)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Removed the Notification Channels entry from the settings side nav;
the channel screens are still reachable at their own route.
- Renamed the `shift+g+n` shortcut from
`NavigateToSettingsNotificationChannels` to
`NavigateToNotificationChannels`, since it no longer points into
Settings. The key binding and target route are unchanged.
- Updated the Settings nav test so it checks that the entry is gone.

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

#### Additional Information

Resolves the TODO linked to SigNoz/engineering-pod#5323.
2026-10-26 01:43:18 +00:00
Vinicius Lourenço
9ba2a25eb9 refactor(workspaced-locked): avoid using images from signoz.io (#12880)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This prevents issues similar to
https://github.com/SigNoz/engineering-pod/issues/6083

I didn't find any other image being imported from signoz.io

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

Closes https://github.com/SigNoz/engineering-pod/issues/6085

Closes https://github.com/SigNoz/engineering-pod/issues/6083

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before (when the images were removed from signoz.io):

<img width="809" height="872" alt="image"
src="https://github.com/user-attachments/assets/4a30aad9-cc46-457c-af88-88758db79c5f"
/>

After:

<img width="970" height="980" alt="image"
src="https://github.com/user-attachments/assets/328b17a9-bcbf-44d3-90df-f5f71beb53a7"
/>
2026-10-23 15:35:31 +00:00
132 changed files with 6032 additions and 2573 deletions

View File

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

View File

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

View File

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

View File

@@ -2239,6 +2239,8 @@ components:
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
@@ -2248,13 +2250,79 @@ components:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
properties:
cliCommand:
type: string
required:
- cliCommand
type: object
CloudintegrationtypesGCPConnectionMethod:
enum:
- manual
- agent
type: string
CloudintegrationtypesGCPIntegrationConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
- telemetryCollectionStrategy
type: object
CloudintegrationtypesGCPLogsCollectionStrategy:
nullable: true
properties:
sinkFilter:
type: string
required:
- sinkFilter
type: object
CloudintegrationtypesGCPMetricsCollectionStrategy:
nullable: true
properties:
metricNames:
items:
type: string
type: array
required:
- metricNames
type: object
CloudintegrationtypesGCPPostableAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
@@ -2277,6 +2345,16 @@ components:
required:
- enabled
type: object
CloudintegrationtypesGCPTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
required:
- metrics
- logs
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -2413,7 +2491,7 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -2648,7 +2726,6 @@ components:
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
@@ -8539,6 +8616,8 @@ components:
type: string
query:
type: string
skipStepAlignment:
type: boolean
stats:
type: boolean
step:

View File

@@ -7,24 +7,66 @@ import (
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
func (g *gcpcloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
var metricNames []string
var sinkFilters []string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
if svcDef.TelemetryCollectionStrategy.GCP == nil {
continue
}
strategy := svcDef.TelemetryCollectionStrategy.GCP
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
}
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
}
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
GCP: connectionArtifact,
}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
@@ -34,3 +76,7 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}

View File

@@ -2,7 +2,6 @@ package implcloudintegration
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -171,18 +170,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
@@ -195,41 +182,36 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
}
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
return nil, errors.Newf(
errors.TypeAlreadyExists,
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
)
}
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
@@ -498,11 +480,45 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
}
// NOTE: not adding stats for services for now.
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
if err == nil {
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
}
return stats, nil
}
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
response := cloudintegrationtypes.NewAgentCheckInResponse(
providerAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
)
if account.AgentReport == nil {
return response, nil
}
if syncState == nil {
return response, nil
}
account.UpdateSyncState(syncState)
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
return response, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil

View File

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

385
frontend/pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -3792,7 +3792,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export enum CloudintegrationtypesGCPConnectionMethodDTO {
manual = 'manual',
agent = 'agent',
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
@@ -4039,7 +4044,10 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
/**
* @type string
*/
cliCommand: string;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
@@ -4078,8 +4086,62 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
/**
* @type string
*/
sinkFilter: string;
} | null;
/**
* @nullable
*/
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
/**
* @type array
*/
metricNames: string[];
} | null;
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
@@ -4234,7 +4296,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -4377,9 +4439,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
*/
deploymentRegion: string;
/**
* @type array,null
* @type array
*/
projectIds: string[] | null;
projectIds: string[];
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
@@ -6054,6 +6116,10 @@ export interface Querybuildertypesv5PromQueryDTO {
* @type string
*/
query?: string;
/**
* @type boolean
*/
skipStepAlignment?: boolean;
/**
* @type boolean
*/

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ export const GlobalShortcuts = {
NavigateToServices: 'shift+s',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -23,7 +24,6 @@ export const GlobalShortcuts = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -34,6 +34,7 @@ export const GlobalShortcutsName = {
NavigateToTraces: 'shift+t',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -44,7 +45,6 @@ export const GlobalShortcutsName = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -59,6 +59,7 @@ export const GlobalShortcutsDescription = {
NavigateToLogs: 'Navigate to Logs Explorer',
NavigateToDashboards: 'Navigate to Dashboards List',
NavigateToAlerts: 'Navigate to Alerts List',
NavigateToNotificationChannels: 'Navigate to Notification Channels',
NavigateToExceptions: 'Navigate to Exceptions List',
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
@@ -68,8 +69,6 @@ export const GlobalShortcutsDescription = {
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',

View File

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

View File

@@ -0,0 +1,5 @@
.waitingMessage {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,147 @@
import { LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
import { useForm } from 'react-hook-form';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import AgentSetupCommand from './AgentSetupCommand';
import styles from './AgentSetup.module.scss';
interface AgentSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onCommandGenerated: () => void;
/** Asks to leave the setup; the parent may confirm before closing. */
onCancel: () => void;
onClose: () => void;
}
function AgentSetup({
footerSlot,
onConnectionMethodChange,
onCommandGenerated,
onCancel,
onClose,
}: AgentSetupProps): JSX.Element {
const {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
} = useAgentAccountSetup({ onClose, onCommandGenerated });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
onChange={onConnectionMethodChange}
disabled={isLoading || Boolean(connectionCommand)}
/>
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
{connectionCommand ? (
<>
<AgentSetupCommand
deploymentProjectId={commandProjectId}
command={connectionCommand}
/>
{modalState === ModalStateEnum.WAITING && (
<Callout
type="info"
showIcon={false}
title={
<div className={styles.waitingMessage}>
<LoaderCircle size={14} className="animate-spin" />
Waiting for GCP account connection, retrying in 10 secs...
</div>
}
testId="gcp-agent-waiting"
/>
)}
{modalState === ModalStateEnum.ERROR && (
<Callout
type="error"
showIcon
title="We couldn't establish a connection to your GCP account. Please try again"
testId="gcp-agent-connection-error"
/>
)}
</>
) : (
<>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</>
)}
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to generate the GCP setup command"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onCancel}
>
{!connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(generateCommand)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-generate-command-btn"
>
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="gcp-check-connection-btn"
>
Check Again
</Button>
)}
</SetupDrawerFooter>
</>
);
}
export default AgentSetup;

View File

@@ -0,0 +1,31 @@
.commands {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.commandsHead {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.mono {
composes: mono from '../shared.module.scss';
}
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
// the code theme sets the block's margin and padding inline, hence !important.
.command {
> div:first-child {
top: var(--spacing-6);
right: var(--spacing-6);
}
> div:last-child {
// The theme's top margin pushes the bordered block under the copy button.
margin: 0 !important;
// Keeps long lines from running underneath the copy button.
padding-right: var(--spacing-24) !important;
}
}

View File

@@ -0,0 +1,32 @@
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
deploymentProjectId: string;
command: string;
}
function AgentSetupCommand({
deploymentProjectId,
command,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid="gcp-agent-command">
<div className={styles.commandsHead}>
<Typography.Text weight="bold" size="base">
Deploy the SigNoz agent
</Typography.Text>
<Typography.Text as="p" size="small" color="muted">
Run this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
contains your SigNoz keys, so do not share it.
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />
</div>
);
}
export default AgentSetupCommand;

View File

@@ -0,0 +1,183 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
DEPLOYMENT_PROJECT_ID,
fillAgentForm,
onClose,
onCommandGenerated,
renderAgentSetup,
mockAgentSetupApi,
AgentSetupApiMock,
} from './agentSetupTestHelpers';
import {
connectionCredentials,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
gcpConnectionCommand,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup form', () => {
let api: AgentSetupApiMock;
beforeEach(() => {
jest.clearAllMocks();
api = mockAgentSetupApi();
});
it('asks only for the deployment project, region and credentials', async () => {
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
connectionCredentials.sigNozApiUrl,
);
});
expect(
screen.getByTestId('gcp-deployment-project-id-input'),
).toBeInTheDocument();
expect(
screen.getByTestId('gcp-deployment-region-select'),
).toBeInTheDocument();
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
'SigNoz integration sets up everything needed',
);
});
it('blocks submission and surfaces validation errors when the form is empty', async () => {
const user = userEvent.setup();
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(
screen.getByText('Please enter the deployment project ID'),
).toBeInTheDocument();
});
expect(screen.getByText('Please select a region')).toBeInTheDocument();
expect(api.createAccountPayload).toBeNull();
});
it('creates an agent account and shows its setup command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
});
expect(api.createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'agent',
deploymentRegion: 'asia-south1',
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
projectIds: [DEPLOYMENT_PROJECT_ID],
},
},
credentials: connectionCredentials,
});
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
gcpConnectionCommand.cliCommand,
);
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
DEPLOYMENT_PROJECT_ID,
);
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-deployment-project-id-input'),
).not.toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(onCommandGenerated).toHaveBeenCalledTimes(1);
expect(api.checkInCalled).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
it('shows the backend error inline and keeps the form when creation fails', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: {
message:
'projectIds must contain only deploymentProjectId for GCP agent setup',
},
}),
),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
'projectIds must contain only deploymentProjectId for GCP agent setup',
);
});
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
});
it('locks the connection method and keeps the submitted project while the command is generating', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'-edited',
);
const commands = await screen.findByTestId('gcp-agent-command');
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
});
});

View File

@@ -0,0 +1,118 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
fillAgentForm,
onClose,
renderAgentSetup,
mockAgentSetupApi,
} from './agentSetupTestHelpers';
import {
connectedAgentAccountResponse,
GCP_AGENT_ACCOUNT_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup connection polling', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAgentSetupApi();
});
it('waits for the agent after showing the command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-waiting');
expect(
screen.queryByTestId('gcp-check-connection-btn'),
).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the agent checks in', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('reports a timeout and lets the user check again', async () => {
const realNow = Date.now.bind(Date);
let clockOffset = 0;
const nowSpy = jest
.spyOn(Date, 'now')
.mockImplementation(() => realNow() + clockOffset);
let pollCount = 0;
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
pollCount += 1;
if (pollCount === 1) {
// Polling started before this response, so jumping the clock
// past the 10 minute window makes this first poll time out.
clockOffset += 11 * 60 * 1000;
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
}
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
}),
);
try {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-check-connection-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
} finally {
nowSpy.mockRestore();
}
});
it('shows a retryable error when the connection status cannot be fetched', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AgentSetup from '../AgentSetup';
import SetupDrawerLayout from '../../SetupDrawerLayout';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
export const onClose = jest.fn() as jest.MockedFunction<
AgentSetupProps['onClose']
>;
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
AgentSetupProps['onConnectionMethodChange']
>;
export const onCommandGenerated = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCommandGenerated']
>;
export const onCancel = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCancel']
>;
export const renderAgentSetup = (): void => {
render(
<SetupDrawerLayout onClose={onClose}>
{(footerSlot): JSX.Element => (
<AgentSetup
footerSlot={footerSlot}
onConnectionMethodChange={onConnectionMethodChange}
onCommandGenerated={onCommandGenerated}
onCancel={onCancel}
onClose={onClose}
/>
)}
</SetupDrawerLayout>,
);
};
export const fillAgentForm = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
DEPLOYMENT_PROJECT_ID,
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
};
export interface AgentSetupApiMock {
readonly createAccountPayload: Record<string, unknown> | null;
readonly checkInCalled: boolean;
}
/** Registers the agent setup endpoints; the polled account stays pending. */
export const mockAgentSetupApi = (): AgentSetupApiMock => {
let createAccountPayload: Record<string, unknown> | null = null;
let checkInCalled = false;
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
// check_in is registered first, it is a more specific path than
// /accounts and msw matches handlers in registration order.
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
checkInCalled = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
createAccountPayload = await req.json();
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
}),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
return {
get createAccountPayload(): Record<string, unknown> | null {
return createAccountPayload;
},
get checkInCalled(): boolean {
return checkInCalled;
},
};
};

View File

@@ -1,142 +0,0 @@
.setupDrawer {
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
// Input and ComboboxSimple default to --border borders, inherited text and
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
// brighter values and duller placeholders. Backgrounds are left alone — both
// components default to transparent and every field sits on an --l2-background
// surface already. Focus borders stay at their per-component defaults.
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--l2-border);
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
--combobox-trigger-border-color: var(--l2-border);
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
padding: var(--spacing-10) var(--spacing-12);
overflow-y: auto;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
}
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.mono {
composes: mono from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.projectIdsSelect {
:global(.ant-select-selector) {
min-height: 36px;
background: var(--l2-background);
border: 1px solid var(--l2-border) !important;
}
&:hover :global(.ant-select-selector),
&:global(.ant-select-focused) :global(.ant-select-selector) {
border-color: var(--l2-border);
box-shadow: none;
}
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
:global(.ant-select-selection-placeholder),
:global(.ant-select-selection-search-input),
:global(.ant-select-selection-item) {
font-size: var(--periscope-font-size-base);
}
:global(.ant-select-selection-placeholder) {
color: var(--l3-foreground);
}
:global(.ant-select-selection-search-input) {
color: var(--l1-foreground);
}
:global(.ant-select-selection-item) {
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
:global(.ant-select-selection-item-remove) {
color: var(--l3-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}

View File

@@ -1,288 +1,88 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
import { Controller, useForm } from 'react-hook-form';
import { popupContainer } from 'utils/selectPopupContainer';
import ConnectionSecretsFields from './ConnectionSecretsFields';
import FieldLabel from './FieldLabel';
import FlowSelector from './FlowSelector';
import SetupGuideCallout from './SetupGuideCallout';
import { GcpSetupFormValues, SetupFlow } from './types';
import styles from './CloudAccountSetupDrawer.module.scss';
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
const DEFAULT_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
import AgentSetup from './AgentSetup/AgentSetup';
import ManualSetup from './ManualSetup/ManualSetup';
import SetupDrawerLayout from './SetupDrawerLayout';
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
const [connectionMethod, setConnectionMethod] =
useState<CloudintegrationtypesGCPConnectionMethodDTO>(
CloudintegrationtypesGCPConnectionMethodDTO.manual,
);
// Set once the agent command exists; closing after that loses the pending setup.
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_VALUES,
});
const [flow, setFlow] = useState<SetupFlow>('manual');
// Pre-fill the deployment/ingestion fields with the fetched credentials.
useEffect(() => {
if (!connectionParams) {
const requestClose = (): void => {
if (hasGeneratedCommand) {
setIsCloseConfirmOpen(true);
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
const footer = (
<div className={styles.footerContainer}>
{submitError && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={clearSubmitError}
title="Failed to connect GCP account"
testId="gcp-connect-error"
>
{submitError}
</Callout>
)}
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
onClose();
};
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
<>
<SetupDrawerLayout onClose={requestClose}>
{(footerSlot): JSX.Element =>
connectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent ? (
<AgentSetup
footerSlot={footerSlot}
onConnectionMethodChange={setConnectionMethod}
onCommandGenerated={(): void => setHasGeneratedCommand(true)}
onCancel={requestClose}
onClose={onClose}
/>
) : (
<ManualSetup
footerSlot={footerSlot}
onConnectionMethodChange={setConnectionMethod}
onClose={onClose}
/>
)
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="base"
footer={footer}
drawerHeaderProps={{ className: styles.title }}
>
<FlowSelector value={flow} onChange={setFlow} />
<SetupGuideCallout />
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
/>
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
required
/>
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip="The GCP region where your OTel Collector will be deployed"
required
/>
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Select
id="gcp-project-ids-select"
className={cx(styles.fullWidth, styles.projectIdsSelect)}
mode="tags"
value={field.value}
onChange={(value): void => field.onChange(value)}
placeholder="Add project IDs…"
tokenSeparators={[',', ' ']}
notFoundContent={null}
suffixIcon={null}
getPopupContainer={popupContainer}
data-testid="gcp-project-ids-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</DrawerWrapper>
</SetupDrawerLayout>
<DialogWrapper
open={isCloseConfirmOpen}
onOpenChange={setIsCloseConfirmOpen}
title="Close GCP agent setup?"
testId="gcp-close-setup-confirm"
footer={
<>
<Button
size="md"
variant="solid"
color="highlight-danger"
onClick={onClose}
testId="gcp-close-setup-btn"
>
Close Setup
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={(): void => setIsCloseConfirmOpen(false)}
testId="gcp-continue-setup-btn"
>
Continue Setup
</Button>
</>
}
>
<Typography>
The agent has not connected yet. If you close now, you will have to start
the installation process again with a new setup command.
</Typography>
</DialogWrapper>
</>
);
}

View File

@@ -10,12 +10,6 @@
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
.methodRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,89 @@
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import styles from './ConnectionMethodSelector.module.scss';
interface ConnectionMethodSelectorProps {
value: CloudintegrationtypesGCPConnectionMethodDTO;
onChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
disabled?: boolean;
}
function ConnectionMethodSelector({
value,
onChange,
disabled,
}: ConnectionMethodSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<Typography.Text
weight="bold"
size="base"
className={styles.drawerSurfaceHead}
>
Connection method
</Typography.Text>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void =>
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
}
disabled={disabled}
disabledTooltip="The connection method can't be changed once setup has started"
items={[
{
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
testId: 'gcp-connection-method-manual',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect Manually
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
testId: 'gcp-connection-method-agent',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect via Agent
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
ConnectionMethodSelector.defaultProps = {
disabled: false,
};
export default ConnectionMethodSelector;

View File

@@ -21,10 +21,6 @@
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}

View File

@@ -9,6 +9,7 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import FormField from './FormField';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
@@ -141,43 +142,32 @@ function ConnectionSecretsFields({
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
key={field.name}
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<FormField
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
error={fieldState.error}
>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
</FormField>
)}
/>
);
})}
</div>

View File

@@ -1,82 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { SetupFlow } from './types';
import styles from './FlowSelector.module.scss';
interface FlowSelectorProps {
value: SetupFlow;
onChange: (flow: SetupFlow) => void;
}
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Connection method
</Typography.Text>
</div>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
items={[
{
value: 'manual',
testId: 'gcp-flow-manual',
label: (
<>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect Manually
</Typography.Text>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: 'agent',
testId: 'gcp-flow-agent',
disabled: true,
disabledTooltip: 'Soon',
label: (
<>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="primary" variant="solid">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,7 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { FieldError } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import styles from './FormField.module.scss';
interface FormFieldProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
error?: FieldError;
children: ReactNode;
}
/** A labelled setup drawer field with its validation message below the input. */
function FormField({
htmlFor,
label,
tooltip,
required,
error,
children,
}: FormFieldProps): JSX.Element {
return (
<div className={styles.drawerSection}>
<FieldLabel
htmlFor={htmlFor}
label={label}
tooltip={tooltip}
required={required}
/>
{children}
{error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{error.message}
</Typography.Text>
)}
</div>
);
}
FormField.defaultProps = {
required: false,
error: undefined,
};
export default FormField;

View File

@@ -0,0 +1,139 @@
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
import { Controller, useForm } from 'react-hook-form';
import ProjectIdsInput from '../../ProjectIdsInput';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import FormField from '../FormField';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import styles from './ManualSetup.module.scss';
interface ManualSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onClose: () => void;
}
function ManualSetup({
footerSlot,
onConnectionMethodChange,
onClose,
}: ManualSetupProps): JSX.Element {
const {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useManualAccountSetup({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
onChange={onConnectionMethodChange}
disabled={isLoading}
/>
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
error={fieldState.error}
>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
</FormField>
)}
/>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
error={fieldState.error}
>
<ProjectIdsInput
id="gcp-project-ids-select"
testId="gcp-project-ids-select"
value={field.value}
onChange={field.onChange}
/>
</FormField>
)}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to connect GCP account"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onClose}
>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</SetupDrawerFooter>
</>
);
}
export default ManualSetup;

View File

@@ -0,0 +1,14 @@
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}

View File

@@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import styles from './SetupDrawerFooter.module.scss';
interface SetupDrawerFooterProps {
slot: HTMLDivElement | null;
errorTitle: string;
error: string | null;
onDismissError: () => void;
onCancel: () => void;
children?: ReactNode;
}
function SetupDrawerFooter({
slot,
errorTitle,
error,
onDismissError,
onCancel,
children,
}: SetupDrawerFooterProps): JSX.Element | null {
if (!slot) {
return null;
}
return createPortal(
<div className={styles.footerContainer}>
{error && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={onDismissError}
title={errorTitle}
testId="gcp-connect-error"
>
{error}
</Callout>
)}
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={onCancel}
testId="gcp-cancel-btn"
>
Cancel
</Button>
{children}
</div>
</div>,
slot,
);
}
SetupDrawerFooter.defaultProps = {
children: undefined,
};
export default SetupDrawerFooter;

View File

@@ -0,0 +1,36 @@
.setupDrawer {
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
overflow-y: auto;
}
// The region menu renders in place (no portal), so it needs its own surface
// to sit above the fields below it.
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
}
.footerSlot {
width: 100%;
}

View File

@@ -0,0 +1,40 @@
import { ReactNode, useState } from 'react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import styles from './SetupDrawerLayout.module.scss';
interface SetupDrawerLayoutProps {
onClose: () => void;
/** Receives the footer slot element that SetupDrawerFooter portals into. */
children: (footerSlot: HTMLDivElement | null) => ReactNode;
}
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
function SetupDrawerLayout({
onClose,
children,
}: SetupDrawerLayoutProps): JSX.Element {
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="extra-wide"
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
testId="gcp-setup-drawer"
>
{children(footerSlot)}
</DrawerWrapper>
);
}
export default SetupDrawerLayout;

View File

@@ -4,24 +4,30 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './SetupGuideCallout.module.scss';
const GCP_INTEGRATION_DOCS_URL =
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
interface SetupGuideCalloutProps {
description: string;
docsUrl: string;
linkLabel: string;
}
function SetupGuideCallout(): JSX.Element {
function SetupGuideCallout({
description,
docsUrl,
linkLabel,
}: SetupGuideCalloutProps): JSX.Element {
return (
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
<Typography.Text as="span" size="base">
Please go through our GCP integration guide, which covers all prerequisites
— service account, IAM roles, and resource setup.
{description}
</Typography.Text>
<a
className={styles.guideLink}
href={GCP_INTEGRATION_DOCS_URL}
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
data-testid="gcp-setup-guide-link"
>
GCP integration guide
{linkLabel}
<ArrowUpRight size={12} />
</a>
</Callout>

View File

@@ -0,0 +1,27 @@
import { GcpSetupFormValues } from './types';
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
export const MANUAL_SETUP_GUIDE = {
description:
'Please follow our setup guide to create the resources needed to complete the integration.',
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'View setup guide',
};
export const AGENT_SETUP_GUIDE = {
description:
'SigNoz integration sets up everything needed to collect telemetry from your project.',
// TODO: point to the agent setup docs once they are published.
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'Learn more',
};

View File

@@ -0,0 +1,54 @@
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
};
interface DeploymentProjectIdFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentProjectIdField({
control,
connectionMethod,
}: DeploymentProjectIdFieldProps): JSX.Element {
return (
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
</FormField>
)}
/>
);
}
export default DeploymentProjectIdField;

View File

@@ -0,0 +1,65 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP region where your OTel Collector will be deployed',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP region where the agent resources are deployed',
};
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
interface DeploymentRegionFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentRegionField({
control,
connectionMethod,
}: DeploymentRegionFieldProps): JSX.Element {
return (
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, styles.regionSelect, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
</FormField>
)}
/>
);
}
export default DeploymentRegionField;

View File

@@ -0,0 +1,19 @@
.mono {
composes: mono from '../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.regionSelect {
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
&.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}

View File

@@ -1,5 +1,4 @@
export type SetupFlow = 'manual' | 'agent';
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { UseFormSetValue } from 'react-hook-form';
import { GcpSetupFormValues } from './types';
// Pre-fill the deployment/ingestion fields with the fetched credentials.
export function usePrefillCredentials(
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
setValue: UseFormSetValue<GcpSetupFormValues>,
): void {
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
}

View File

@@ -3,13 +3,15 @@ import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { Form } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import ProjectIdsInput from '../ProjectIdsInput';
import styles from './AccountSettingsDrawer.module.scss';
@@ -40,6 +42,10 @@ function AccountSettingsDrawer({
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
// The backend rejects updates to agent accounts, so their settings are read-only.
const isAgentAccount =
gcpConfig?.connection_method ===
CloudintegrationtypesGCPConnectionMethodDTO.agent;
return (
<DrawerWrapper
@@ -65,20 +71,23 @@ function AccountSettingsDrawer({
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
gcpConnectionMethod={gcpConfig?.connection_method}
/>
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
{!isAgentAccount && (
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
)}
</div>
}
>
@@ -122,32 +131,42 @@ function AccountSettingsDrawer({
<div className={styles.regionSelector}>
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
{isAgentAccount ? (
<div
className={styles.regionSelectorDescription}
data-testid="gcp-agent-monitored-projects"
>
{gcpConfig?.project_ids.join(', ')}
</div>
) : (
<>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<ProjectIdsInput
id="gcp-edit-project-ids-select"
testId="gcp-edit-project-ids-select"
value={projectIds}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
/>
</Form.Item>
</>
)}
</div>
</div>
</Form>

View File

@@ -0,0 +1,5 @@
.projectIds {
// The multi-value trigger is content-box, so full width needs its padding counted in.
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,43 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import cx from 'classnames';
import styles from './ProjectIdsInput.module.scss';
interface ProjectIdsInputProps {
id: string;
testId: string;
value: string[];
onChange: (projectIds: string[]) => void;
className?: string;
}
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
function ProjectIdsInput({
id,
testId,
value,
onChange,
className,
}: ProjectIdsInputProps): JSX.Element {
return (
<ComboboxSimple
id={id}
testId={testId}
className={cx(styles.projectIds, className)}
multiple
allowCreate
value={value}
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
placeholder="Add project IDs…"
inputPlaceholder="Type a project ID and press Enter"
emptyPlaceholder="Type a project ID to add it"
withPortal={false}
/>
);
}
ProjectIdsInput.defaultProps = {
className: undefined,
};
export default ProjectIdsInput;

View File

@@ -1,19 +1,18 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { toast } from '@signozhq/ui/sonner';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNT_URL,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAccountConfig,
listAccountsResponse,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
@@ -31,31 +30,29 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
const onClose = jest.fn();
const setActiveAccount = jest.fn();
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>
</MockQueryClientProvider>,
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>,
);
};
/** The antd tags Select renders its text input inside the testId wrapper. */
const getProjectIdsInput = (): HTMLElement =>
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
'combobox',
);
/** Each selected tag carries an antd "close" icon that removes it. */
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
/** Each selected project ID renders as a pill with its own remove button. */
const getProjectIdRemoveButtons = (): HTMLElement[] =>
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
'close',
/^Remove /,
);
describe('GCP AccountSettingsDrawer', () => {
@@ -86,8 +83,9 @@ describe('GCP AccountSettingsDrawer', () => {
screen.getByText(gcpAccountConfig.deployment_region),
).toBeInTheDocument();
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
gcpAccountConfig.project_ids.forEach((projectId) => {
expect(screen.getByTitle(projectId)).toBeInTheDocument();
expect(projectIdsInput).toHaveTextContent(projectId);
});
});
@@ -100,7 +98,7 @@ describe('GCP AccountSettingsDrawer', () => {
'true',
);
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
@@ -114,7 +112,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -141,8 +139,7 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).toHaveBeenCalledWith({
...gcpAccount,
config: {
deployment_region: gcpAccountConfig.deployment_region,
deployment_project_id: gcpAccountConfig.deployment_project_id,
...gcpAccountConfig,
project_ids: ['project-a', 'project-b', 'project-c'],
},
});
@@ -159,9 +156,9 @@ describe('GCP AccountSettingsDrawer', () => {
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
while (getProjectIdTagRemoveButtons().length > 0) {
while (getProjectIdRemoveButtons().length > 0) {
// eslint-disable-next-line no-await-in-loop
await user.click(getProjectIdTagRemoveButtons()[0]);
await user.click(getProjectIdRemoveButtons()[0]);
}
await waitFor(() => {
@@ -194,7 +191,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -212,31 +209,4 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,104 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { CloudAccount } from 'container/Integrations/types';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAgentAccount,
listAccountsResponse,
} from './mockData';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
render(
<AccountSettingsDrawer
onClose={onClose}
account={account}
setActiveAccount={setActiveAccount}
/>,
);
};
describe('GCP AccountSettingsDrawer by connection method', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listAccountsResponse)),
),
);
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
renderDrawer(gcpAgentAccount);
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
'my-agent-project',
);
expect(
screen.queryByTestId('gcp-edit-project-ids-select'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-update-account-btn'),
).not.toBeInTheDocument();
});
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
const user = userEvent.setup();
renderDrawer(gcpAgentAccount);
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
).resolves.toBeInTheDocument();
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,128 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectedAgentAccountResponse,
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
const generateAgentCommand = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
};
describe('GCP setup drawer close confirmation', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('closes right away when no setup command has been generated', async () => {
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await user.click(screen.getByTestId('gcp-cancel-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
});
it('asks before cancelling a pending agent setup and can continue it', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByTestId('gcp-cancel-btn'));
await screen.findByText(/start the installation process again/i);
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
await waitFor(() => {
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the user confirms', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByRole('button', { name: 'Close' }));
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('closes without asking once the agent connects', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
@@ -15,8 +14,9 @@ import {
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
// `useManualAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept — so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
@@ -24,14 +24,12 @@ jest.mock('../../../../../api/common/logEvent', () => ({
default: jest.fn(),
}));
const onClose = jest.fn();
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<CloudAccountSetupDrawer onClose={onClose} />
</MockQueryClientProvider>,
);
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP CloudAccountSetupDrawer', () => {
@@ -109,6 +107,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
});
it('creates the account, checks the agent in, and closes the drawer', async () => {
// A slow check-in keeps the connection in flight long enough to observe it.
server.use(
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
checkInPayload = await req.json();
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
}),
);
const user = userEvent.setup();
renderDrawer();
@@ -127,13 +132,21 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,project-b,');
await addProjectIds(user, 'gcp-project-ids-select', [
'project-a',
'project-b',
]);
await user.click(screen.getByTestId('gcp-connect-account-btn'));
// Switching away mid-request would let its success close the other form.
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await waitFor(() => {
expect(createAccountPayload).not.toBeNull();
});
@@ -141,6 +154,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
expect(createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'manual',
deploymentRegion: 'asia-south1',
deploymentProjectId: 'my-deployment-project-123',
projectIds: ['project-a', 'project-b'],
@@ -193,10 +207,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,');
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
await user.click(screen.getByTestId('gcp-connect-account-btn'));

View File

@@ -0,0 +1,101 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP setup drawer connection method switch', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('switches between the manual and agent connection methods inside the same drawer', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await screen.findByTestId('gcp-generate-command-btn');
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-connect-account-btn'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-manual'));
await screen.findByTestId('gcp-account-name-input');
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
// The drawer itself stays mounted, so it does not replay its open animation.
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
});
it('keeps the drawer width the same across connection methods and setup steps', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
});
});

View File

@@ -1,4 +1,7 @@
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CloudAccount,
GCPCloudAccountConfig,
@@ -17,6 +20,7 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
export const gcpAccountConfig: GCPCloudAccountConfig = {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deployment_region: 'asia-south1',
deployment_project_id: 'my-deployment-project-123',
project_ids: ['project-a', 'project-b'],
@@ -30,6 +34,19 @@ export const gcpAccount: CloudAccount = {
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const gcpAgentAccount: CloudAccount = {
id: 'acc-gcp-agent-1',
cloud_account_id: 'acc-gcp-agent-1',
providerAccountId: 'my-agent-project',
config: {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-agent-project',
project_ids: ['my-agent-project'],
},
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const listAccountsResponse = {
status: 'success',
data: { accounts: [] },
@@ -63,3 +80,53 @@ export const checkInResponse = {
status: 'success',
data: {},
};
export const gcpConnectionCommand = {
cliCommand:
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
};
export const createAgentAccountResponse = {
status: 'success',
data: {
id: CLOUD_INTEGRATION_ID,
connectionArtifact: { gcp: gcpConnectionCommand },
},
};
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
const pendingAgentAccount = {
id: CLOUD_INTEGRATION_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: 'agent',
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'asia-south1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const pendingAgentAccountResponse = {
status: 'success',
data: pendingAgentAccount,
};
export const connectedAgentAccountResponse = {
status: 'success',
data: {
...pendingAgentAccount,
providerAccountId: 'my-deployment-project-123',
agentReport: {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
},
},
};

View File

@@ -0,0 +1,17 @@
import { screen, userEvent } from 'tests/test-utils';
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
export const addProjectIds = async (
user: ReturnType<typeof userEvent.setup>,
testId: string,
projectIds: string[],
): Promise<void> => {
await user.click(screen.getByTestId(testId));
const input = await screen.findByPlaceholderText(
'Type a project ID and press Enter',
);
for (const projectId of projectIds) {
// eslint-disable-next-line no-await-in-loop
await user.type(input, `${projectId}{Enter}`);
}
};

View File

@@ -3,6 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Modal } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
INTEGRATION_TELEMETRY_EVENTS,
@@ -17,10 +18,12 @@ function RemoveIntegrationAccount({
cloudProvider,
accountId,
onRemoveIntegrationAccountSuccess,
gcpConnectionMethod,
}: {
cloudProvider: string;
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -44,7 +47,7 @@ function RemoveIntegrationAccount({
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
accountId,
integration: cloudProvider,
});
@@ -73,6 +76,20 @@ function RemoveIntegrationAccount({
manually when connecting this account.
</>
);
} else if (
cloudProvider === INTEGRATION_TYPES.GCP &&
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it, and the agent
will disable the telemetry resources it created in your GCP project. <br />
<br />
After that, you can delete the signoz-integration-bootstrap Infrastructure
Manager deployment, the signoz-integration-bootstrap service account and the
signoz-integration secrets that were created while connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
@@ -129,4 +146,8 @@ function RemoveIntegrationAccount({
);
}
RemoveIntegrationAccount.defaultProps = {
gcpConnectionMethod: undefined,
};
export default RemoveIntegrationAccount;

View File

@@ -0,0 +1,51 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
const gcpAccountDto: CloudintegrationtypesAccountDTO = {
id: 'acc-gcp-1',
orgId: 'org-1',
provider: 'gcp',
providerAccountId: 'my-project',
removedAt: null,
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-project',
deploymentRegion: 'us-central1',
projectIds: ['my-project'],
},
},
};
describe('mapAccountDtoToGcpCloudAccount', () => {
it('maps the GCP config, including its connection method', () => {
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
expect(account?.config).toStrictEqual({
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-project',
project_ids: ['my-project'],
});
});
it('skips an account that has no GCP config', () => {
expect(
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
).toBeNull();
});
it('skips an account the agent has not checked in for yet', () => {
expect(
mapAccountDtoToGcpCloudAccount({
...gcpAccountDto,
providerAccountId: null,
}),
).toBeNull();
});
});

View File

@@ -51,7 +51,8 @@ export function mapAccountDtoToAzureCloudAccount(
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
const gcpConfig = account.config.gcp;
if (!account.providerAccountId || !gcpConfig) {
return null;
}
@@ -59,9 +60,10 @@ export function mapAccountDtoToGcpCloudAccount(
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
connection_method: gcpConfig.connectionMethod,
deployment_region: gcpConfig.deploymentRegion,
deployment_project_id: gcpConfig.deploymentProjectId,
project_ids: gcpConfig.projectIds,
},
status: {
integration: {

View File

@@ -1,3 +1,4 @@
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import {
AWSCloudAccountConfig,
AWSServiceConfig,
@@ -102,6 +103,7 @@ export interface AzureCloudAccountConfig {
}
export interface GCPCloudAccountConfig {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
deployment_region: string;
deployment_project_id: string;
project_ids: string[];

View File

@@ -65,8 +65,7 @@ import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { checkVersionState, isModifierKeyPressed } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { openInNewTab } from 'utils/navigation';
@@ -714,7 +713,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
onClickHandler(ROUTES.BILLING, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
registerShortcut(GlobalShortcuts.NavigateToNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
@@ -744,7 +743,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);

View File

@@ -343,14 +343,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
},
{
key: ROUTES.BILLING,
label: 'Billing',

View File

@@ -0,0 +1,67 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
export const onConnected = jest.fn() as jest.MockedFunction<
PollingProps['onConnected']
>;
export const onTimeout = jest.fn() as jest.MockedFunction<
PollingProps['onTimeout']
>;
export const onError = jest.fn() as jest.MockedFunction<
PollingProps['onError']
>;
export const callbacks = { onConnected, onTimeout, onError };
export const ACCOUNT_ID = 'acc-gcp-1';
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
const agentReport = {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
};
export const pendingAccount: CloudintegrationtypesAccountDTO = {
id: ACCOUNT_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'us-central1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const connectedAccount: CloudintegrationtypesAccountDTO = {
...pendingAccount,
providerAccountId: 'my-deployment-project-123',
agentReport,
};
export const mockAccountResponses = (
...accounts: CloudintegrationtypesAccountDTO[]
): { requestCount: () => number } => {
let count = 0;
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
const account = accounts[Math.min(count, accounts.length - 1)];
count += 1;
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
}),
);
return { requestCount: (): number => count };
};

View File

@@ -0,0 +1,94 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
ACCOUNT_URL,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onError,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,
}),
{ wrapper: AllTheProviders },
);
};
describe('useAgentConnectionPolling outcomes', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps polling until the agent checks in, then reports the connection', async () => {
const { requestCount } = mockAccountResponses(
pendingAccount,
pendingAccount,
connectedAccount,
);
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() => expect(requestCount()).toBe(2));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() =>
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
);
expect(onTimeout).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('does not treat a removed account as connected', async () => {
const { requestCount } = mockAccountResponses({
...connectedAccount,
removedAt: '2026-01-01T00:00:00Z',
});
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('reports an error when the account request fails', async () => {
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderPolling();
await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (
initialEnabled: boolean,
): ((enabled: boolean) => void) => {
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled,
...callbacks,
}),
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
);
return (enabled: boolean): void => rerender({ enabled });
};
describe('useAgentConnectionPolling polling window', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('does not poll while disabled', async () => {
const { requestCount } = mockAccountResponses(connectedAccount);
renderPolling(false);
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
});
expect(requestCount()).toBe(0);
expect(onConnected).not.toHaveBeenCalled();
});
it('reports a timeout once the deadline passes without a check-in', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
renderPolling(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('starts the timeout clock when polling is enabled, not on mount', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
const setEnabled = renderPolling(false);
// Longer than the whole window passes before polling is turned on.
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
});
setEnabled(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -87,8 +87,7 @@ export function useAccountSettingsDrawer({
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
...accountConfig,
project_ids: values.projectIds || [],
};

View File

@@ -0,0 +1,138 @@
import { useCallback, useState } from 'react';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
import { useGcpAccountSetup } from './useGcpAccountSetup';
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
interface UseAgentAccountSetupProps {
onClose: () => void;
onCommandGenerated: () => void;
}
interface UseAgentAccountSetup {
isLoading: boolean;
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
connectionCommand: string | null;
/** The project the generated command targets, as submitted. */
commandProjectId: string;
modalState: ModalStateEnum;
checkConnectionAgain: () => void;
}
export function useAgentAccountSetup({
onClose,
onCommandGenerated,
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const [commandProjectId, setCommandProjectId] = useState('');
const [accountId, setAccountId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const generateCommand = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
// The agent monitors only the project it is deployed in.
projectIds: [values.deploymentProjectId],
},
values,
);
const command = account.connectionArtifact.gcp?.cliCommand;
if (!command) {
setSubmitError(GENERATE_COMMAND_ERROR);
return;
}
setAccountId(account.id);
setCommandProjectId(values.deploymentProjectId);
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
onCommandGenerated();
void logEvent('GCP Integration: Account connection command generated', {
id: account.id,
});
}, GENERATE_COMMAND_ERROR),
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
);
const handleAgentConnected = useCallback(
(account: CloudintegrationtypesAccountDTO): void => {
handleConnected({
cloudIntegrationId: account.id,
providerAccountId: account.providerAccountId,
});
},
[handleConnected],
);
const handleTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('GCP Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handlePollingError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const checkConnectionAgain = useCallback((): void => {
setModalState(ModalStateEnum.WAITING);
}, []);
useAgentConnectionPolling({
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
onConnected: handleAgentConnected,
onTimeout: handleTimeout,
onError: handlePollingError,
});
return {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
};
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
export const POLL_INTERVAL_MS = 10 * 1000;
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
interface UseAgentConnectionPollingProps {
accountId: string;
/** Polls exactly while true; the caller decides when there is an account to poll. */
enabled: boolean;
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
onTimeout: () => void;
onError: () => void;
}
/**
* Polls a GCP agent account until the agent checks in. The timeout is measured
* from when polling gets enabled, so re-enabling it starts a fresh window.
*/
export function useAgentConnectionPolling({
accountId,
enabled,
onConnected,
onTimeout,
onError,
}: UseAgentConnectionPollingProps): void {
const startTimeRef = useRef<number | null>(null);
useEffect(() => {
startTimeRef.current = enabled ? Date.now() : null;
}, [enabled]);
useGetAccount(
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
{
query: {
enabled,
refetchInterval: POLL_INTERVAL_MS,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
onConnected(account);
return;
}
const startTime = startTimeRef.current ?? Date.now();
if (Date.now() - startTime >= CONNECTION_TIMEOUT_MS) {
onTimeout();
}
},
onError: () => {
onError();
},
},
},
);
}

View File

@@ -2,16 +2,15 @@ import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesGCPPostableAccountConfigDTO,
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
@@ -22,23 +21,36 @@ import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
interface UseGcpAccountSetupProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
interface UseGcpAccountSetup {
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
isLoading: boolean;
submitError: string | null;
setSubmitError: (message: string | null) => void;
clearSubmitError: () => void;
createGcpAccount: (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
runSubmit: (
work: () => Promise<void>,
fallbackMessage: string,
) => Promise<void>;
handleConnected: (payload: {
cloudIntegrationId: string;
providerAccountId: string | null;
}) => void;
}
export function useCloudAccountSetupDrawer({
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
export function useGcpAccountSetup({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -48,7 +60,6 @@ export function useCloudAccountSetupDrawer({
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -63,44 +74,15 @@ export function useCloudAccountSetupDrawer({
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
const createGcpAccount = useCallback(
async (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
const response = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
config: { gcp: gcpConfig },
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
@@ -110,56 +92,69 @@ export function useCloudAccountSetupDrawer({
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
},
});
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
return response.data;
},
[connectionParams, createAccount],
);
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
const runSubmit = useCallback(
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
await work();
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
fallbackMessage,
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
[],
);
const handleConnected = useCallback(
({
cloudIntegrationId,
providerAccountId,
}: {
cloudIntegrationId: string;
providerAccountId: string | null;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId,
providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
onClose();
},
[onClose, queryClient],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import logEvent from '../../../api/common/logEvent';
import { useGcpAccountSetup } from './useGcpAccountSetup';
interface UseManualAccountSetupProps {
onClose: () => void;
}
interface UseManualAccountSetup {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useManualAccountSetup({
onClose,
}: UseManualAccountSetupProps): UseManualAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const { mutateAsync: checkIn } = useAgentCheckIn();
const connectAccount = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
// Step 1: create the integration account.
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
values,
);
const cloudIntegrationId = account.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnected({ cloudIntegrationId, providerAccountId });
}, 'Failed to connect GCP account'),
[checkIn, createGcpAccount, handleConnected, runSubmit],
);
return {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,11 @@
*/
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesGCPConnectionMethodDTO,
type CloudintegrationtypesPostableAccountDTO,
type CloudintegrationtypesUpdatableServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
@@ -18,9 +22,14 @@ import {
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
GCP_CONNECTION_METHODS,
MUTATION_STATES,
type MutationState,
NEW_ACCOUNT_CHECK_INS,
type NewAccountCheckIn,
createdAccountResponse,
newAccountId,
newAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
seedServiceSignals,
@@ -36,6 +45,11 @@ import {
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
const FAILED_REQUEST = {
status: 'error',
error: { code: 'internal', message: 'Request failed' },
};
/**
* Answers a mutation by the control's state: never, with a failure, or with the
* success body the caller builds.
@@ -48,16 +62,33 @@ const mutationResolver =
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Request failed' },
}),
)
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: ok(req, res, ctx, ...rest);
};
/**
* The account the connect flow just created answers by the check-in control;
* every other account is the page's data.
*/
const accountResolver =
(
provider: CloudProvider,
checkIn: NewAccountCheckIn,
existing: MockResolver,
): MockResolver =>
(req, res, ctx, ...rest) => {
if (String(req.params.id) !== newAccountId(provider)) {
return existing(req, res, ctx, ...rest);
}
return checkIn === 'error'
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: res(
ctx.status(200),
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
);
};
const PROVIDER = 'Cloud integration · provider';
const MUTATIONS = 'Cloud integration · mutations';
const SERVICES = 'Cloud integration · services';
@@ -71,6 +102,17 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: CLOUD_PROVIDERS,
value: 'aws',
}),
gcpConnectionMethod:
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
'GCP connection method',
{
group: PROVIDER,
description:
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
options: GCP_CONNECTION_METHODS,
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
},
),
accounts: countControl('Connected accounts', {
group: PROVIDER,
description:
@@ -99,6 +141,13 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: MUTATION_STATES,
value: 'success',
}),
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
group: MUTATIONS,
description:
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
options: NEW_ACCOUNT_CHECK_INS,
value: 'checked-in',
}),
accountUpdate: choiceControl<MutationState>('Account settings update', {
group: MUTATIONS,
description:
@@ -172,9 +221,19 @@ export const cloudIntegrationMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
mutationResolver(values.accountCreate, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
),
mutationResolver(values.accountCreate, async (req, res, ctx) => {
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
return res(
ctx.status(201),
ctx.json(
createdAccountResponse(
values.provider,
body.config?.gcp?.connectionMethod,
),
),
);
}),
),
rest.put(
@@ -182,21 +241,41 @@ export const cloudIntegrationMocks = defineStoryMocks({
mutationResolver(values.accountUpdate, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(accountResponse(values.provider, String(req.params.id))),
ctx.json(
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
accountResolver(
values.provider,
values.newAccountCheckIn,
response.json((req) =>
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => accountsResponse(values.provider, values.accounts)),
response.json(() =>
accountsResponse(
values.provider,
values.accounts,
values.gcpConnectionMethod,
),
),
),
],
config: (values) => ({
@@ -264,6 +343,17 @@ export const connectAzureAccount = async (
);
};
const addGcpProjectId = async (
testId: string,
projectId: string,
): Promise<void> => {
await userEvent.click(screen.getByTestId(testId));
await userEvent.type(
await screen.findByPlaceholderText('Type a project ID and press Enter'),
`${projectId}{Enter}`,
);
};
export const connectGcpAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
@@ -278,14 +368,40 @@ export const connectGcpAccount = async (
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
await userEvent.type(
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
'project-a,',
);
await addGcpProjectId('gcp-project-ids-select', 'project-a');
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
};
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
export const openGcpAgentSetup = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByTestId(
'gcp-connection-method-agent',
undefined,
untilLoaded,
),
);
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
};
export const generateGcpAgentCommand = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openGcpAgentSetup(canvasElement);
await userEvent.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'signoz-prod-4471',
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
};
export const openEditAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/edit account/i,
@@ -308,6 +424,17 @@ export const updateAwsAccount = async (
);
};
export const updateGcpProjects = async (
canvasElement: HTMLElement,
projectId: string,
): Promise<void> => {
await openEditAccount(canvasElement);
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};
export const updateTaggedAccount = async (
canvasElement: HTMLElement,
tag: string,

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import { screen, userEvent, within } from 'storybook/test';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -12,9 +13,13 @@ import {
connectAzureAccount,
connectGcpAccount,
expectToast,
generateGcpAgentCommand,
openConnectFlow,
openEditAccount,
openGcpAgentSetup,
untilLoaded,
updateAwsAccount,
updateGcpProjects,
updateTaggedAccount,
} from './CloudIntegration.stories.mocks';
@@ -251,6 +256,111 @@ export const GcpRegionOpen: Story = {
},
};
/**
* The GCP connect drawer on its agent connection method: only the deployment
* project, region and credentials, since the agent monitors the project it is
* deployed in.
*/
export const GcpAgentSetup: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openGcpAgentSetup(canvasElement);
},
};
/**
* The setup command the agent connection generates, with the drawer waiting for
* the agent to check in. Set New account check-in to
* `checked-in` or `error` to see it connect or fail.
*/
export const GcpAgentCommand: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
},
};
/** The agent checks in on the first poll, so the drawer closes with a toast. */
export const GcpAgentConnectedToast: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
play: async ({ canvasElement, args }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
if (args.newAccountCheckIn === 'checked-in') {
await expectToast(/gcp account connected successfully/i);
}
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const GcpAgentConnectionError: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId(
'gcp-agent-connection-error',
undefined,
untilLoaded,
);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const GcpAgentCloseConfirm: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await userEvent.click(
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
},
};
/**
* The settings of a GCP account connected through the agent: read-only, since
* the backend rejects updates to agent accounts.
*/
export const GcpAgentAccountSettings: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await screen.findByTestId(
'gcp-agent-monitored-projects',
undefined,
untilLoaded,
);
},
};
/** Disconnecting an agent account, which names what is left to clean up. */
export const GcpAgentDisconnect: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
);
await screen.findByText(
/signoz-integration-bootstrap Infrastructure/i,
undefined,
untilLoaded,
);
},
};
/**
* Saving the regions of a connected AWS account. Set Account settings update
* to `error` for the failure toast.
@@ -301,7 +411,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
export const GcpAccountSettingsUpdatedToast: Story = {
args: { provider: 'gcp' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
@@ -312,7 +422,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
export const GcpAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'gcp', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}

View File

@@ -3,6 +3,7 @@
* Do not hand-edit: regenerate instead.
*/
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import type {
CloudintegrationtypesAccountConfigDTO,
CloudintegrationtypesAccountDTO,
@@ -23,6 +24,19 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
export type MutationState = (typeof MUTATION_STATES)[number];
export const GCP_CONNECTION_METHODS = [
CloudintegrationtypesGCPConnectionMethodDTO.manual,
CloudintegrationtypesGCPConnectionMethodDTO.agent,
] as const;
/** How the poll for the account the connect flow just created answers. */
export const NEW_ACCOUNT_CHECK_INS = [
'checked-in',
'pending',
'error',
] as const;
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
interface CloudServiceSeed {
id: string;
title: string;
@@ -266,6 +280,7 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
const accountConfig = (
provider: CloudProvider,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountConfigDTO => {
if (provider === 'aws') {
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
@@ -280,8 +295,23 @@ const accountConfig = (
};
}
// The backend only accepts the deployment project as an agent account's scope.
if (
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471'],
},
};
}
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
@@ -292,12 +322,13 @@ const accountConfig = (
const account = (
provider: CloudProvider,
index: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountDTO => ({
id: `${provider}-account-${index + 1}`,
orgId: 'story-org',
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
config: accountConfig(provider, gcpConnectionMethod),
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
@@ -311,11 +342,12 @@ const account = (
export const accountsResponse = (
provider: CloudProvider,
count: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): ListAccounts200 => ({
status: 'success',
data: {
accounts: Array.from({ length: count }, (_, index) =>
account(provider, index),
account(provider, index, gcpConnectionMethod),
),
},
});
@@ -323,11 +355,52 @@ export const accountsResponse = (
export const accountResponse = (
provider: CloudProvider,
id: string,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): GetAccount200 => {
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
return { status: 'success', data: account(provider, index) };
return {
status: 'success',
data: account(provider, index, gcpConnectionMethod),
};
};
/** The backend's command template, filled in for the story's project and region. */
const GCP_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
--region 'us-central1' \\
--gcp-project 'signoz-prod-4471' \\
--integration-account-id 'gcp-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
export const newAccountId = (provider: CloudProvider): string =>
`${provider}-account-new`;
/**
* The account the connect flow just created, as its connection poll sees it. Only
* GCP's agent connection polls, so a GCP account here is an agent one.
*/
export const newAccountResponse = (
provider: CloudProvider,
isCheckedIn: boolean,
): GetAccount200 => {
const created = {
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
id: newAccountId(provider),
};
return {
status: 'success',
data: isCheckedIn
? created
: { ...created, providerAccountId: null, agentReport: null },
};
};
/**
@@ -541,14 +614,16 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
/**
* The connection URL is what the setup flow opens in a new tab, which the story
* reports through the navigation overlay rather than following.
* reports through the navigation overlay rather than following. GCP returns its
* setup command only for an agent connection; a manual one gets none.
*/
export const createdAccountResponse = (
provider: CloudProvider,
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
): CreateAccount201 => ({
status: 'success',
data: {
id: `${provider}-account-new`,
id: newAccountId(provider),
connectionArtifact: {
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
@@ -559,6 +634,10 @@ export const createdAccountResponse = (
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
...(gcpConnectionMethod ===
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
gcp: GCP_AGENT_COMMAND,
}),
},
},
});

View File

@@ -44,7 +44,6 @@ describe('SettingsPage nav sections', () => {
'settings-page-sidenav',
'workspace',
'account',
'notification-channels',
'billing',
'roles',
'members',
@@ -56,6 +55,12 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it('does not render "notification-channels" element', () => {
expect(
screen.queryByTestId('notification-channels'),
).not.toBeInTheDocument();
});
it.each(['Identity & Access', 'Authentication'])(
'renders "%s" section title',
(text) => {

View File

@@ -1,3 +1,12 @@
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
export const infoData = [
{
id: 'infoBlock-1',
@@ -43,7 +52,7 @@ export const enterpriseGradeValuesData = [
export const customerStoriesData = [
{
key: 'story-subomi-oluwalana',
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
avatar: subomiOluwalanaUrl,
personName: 'Subomi Oluwalana',
role: 'Founder & CEO at Convoy',
customerName: 'Convoy',
@@ -54,7 +63,7 @@ export const customerStoriesData = [
},
{
key: 'story-dhruv-garg',
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
avatar: dhruvGargUrl,
personName: 'Dhruv Garg',
role: 'Tech Lead at Nudge',
customerName: 'Nudge',
@@ -65,7 +74,7 @@ export const customerStoriesData = [
},
{
key: 'story-vivek-bhakta',
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
avatar: vivekBhaktaUrl,
personName: 'Vivek Bhakta',
role: 'CTO at Wombo AI',
customerName: 'Wombo AI',
@@ -75,7 +84,7 @@ export const customerStoriesData = [
},
{
key: 'story-pranay-narang',
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
avatar: pranayNarangUrl,
personName: 'Pranay Narang',
role: 'Engineering at Azodha',
customerName: 'Azodha',
@@ -85,7 +94,7 @@ export const customerStoriesData = [
},
{
key: 'story-Sheheryar-Sewani',
avatar: 'https://signoz.io/img/users/shey.webp',
avatar: sheheryarSewaniUrl,
personName: 'Sheheryar Sewani',
role: 'Seasoned Rails Dev & Founder',
customerName: '',
@@ -96,7 +105,7 @@ export const customerStoriesData = [
},
{
key: 'story-daniel-schell',
avatar: 'https://signoz.io/img/users/daniel.webp',
avatar: danielSchellUrl,
personName: 'Daniel Schell',
role: 'Founder & CTO at Airlockdigital',
customerName: 'Airlockdigital',
@@ -106,7 +115,7 @@ export const customerStoriesData = [
},
{
key: 'c-story-6',
avatar: 'https://signoz.io/img/users/go-frendi.webp',
avatar: goFrendiGunawanUrl,
personName: 'Go Frendi Gunawan',
role: 'Data Engineer at Ctlyst.id',
customerName: 'Ctlyst.id',
@@ -116,7 +125,7 @@ export const customerStoriesData = [
},
{
key: 'story-anselm-eickhoff',
avatar: 'https://signoz.io/img/users/anselm.jpg',
avatar: anselmEickhoffUrl,
personName: 'Anselm Eickhoff',
role: 'Software Architect',
customerName: '',

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