Compare commits

...

16 Commits

Author SHA1 Message Date
vikrantgupta25
5a9c2d59a5 fix(members): drop the duplicate user refetch after a role change
The hook invalidated the user query while EditMemberDrawer also refetches
it on every save and retry path, so a role change fired two identical
GET /api/v2/users/{id} calls. This was hidden before, when the hook owned
a separate roles query.

The drawer already refreshes on all four paths, so ownership stays there.

Assisted-by: Claude Fable 5
2026-08-10 18:28:37 +05:30
vikrantgupta25
d0a640517a feat(members): assign member roles through the user_roles API
Role assignment used the deprecated POST /api/v2/users/{id}/roles and
DELETE /api/v2/users/{id}/roles/{roleId}. It now uses POST and DELETE on
/api/v2/user_roles.

The delete route is keyed by the user_role join row rather than the role,
and the old source of truth dropped that id. Roles now come from
useGetUser, whose userRoles carry it. EditMemberDrawer already issues the
same query, so the two share one request and the drawer needs no change.

Assisted-by: Claude Fable 5
2026-08-10 18:12:41 +05:30
Pandey
f44d6c7c84 docs(contributing): document the kind/spec envelope for sum types (#12494)
#### Description

- Documents the kind/spec envelope pattern for sum types in
`docs/contributing/go/types.md`: the envelope shape, why it goes at the
point of variance rather than the resource root, the tagging-style
rationale (adjacently tagged vs internally tagged vs sibling optional
fields), the validating `UnmarshalJSON`, the OpenAPI variant structs,
and the data-migration-vs-storable-twin trade-off for legacy persisted
shapes.
- Examples are generic (`FooConfig` with `bar`/`baz` kinds), with
`RuleThresholdData`, `EvaluationEnvelope` and the dashboard plugins as
the in-tree references.
- Cross-links from `handler.md`'s "`oneOf` with a discriminator"
section, which keeps owning the schema mechanics.
2026-08-10 11:22:33 +00:00
Gaurav Tewari
90d280d871 feat: add ai explorer tab (#12484)
#### Description

So in this PR , we have introduced a new rapper for AI o11y explorer .
we will be introducing new changes inside this tab.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/39/views/11?filterQuery=assignee%3Atewarig&pane=issue&itemId=223105731&issue=SigNoz%7Cengineering-pod%7C5842

#### Screenshots / Screen Recordings

This is just a demo of Shell -


https://github.com/user-attachments/assets/f647def2-f43d-46e7-850c-98330a7232b7



#### Additional Information

We have enabled this Shell now , since anyway. ai o11y is behind a flag.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-10 10:20:18 +00:00
Gaurav Tewari
1ab44d4244 feat(integrations): add GCP cloud integration (#12210)
## Pull Request

---

### 📄 Summary
> Why does this change exist?
> What problem does it solve, and why is this the right approach?

Adds **Google Cloud Platform** as a cloud integration, bringing GCP to
parity with the existing AWS and Azure integrations. Users can now
connect a GCP account and manage which projects are monitored, directly
from the Integrations UI.

The integration reuses the shared cloud-integration surfaces (services
list, service details, account actions, header) and adds the
GCP-specific data-entry flows on top

doc -
https://app.notion.com/p/signoz/GCP-Integration-frontend-requirements-39cfcc6bcd1980b39428e1e66a77893b#39cfcc6bcd19808aa9bccef55661bf9d

Artifacts-
https://claude.ai/design/p/3591c3c0-3cc5-42ca-9ff9-dc8698e4d4dc?file=GCP+Integration.dc.html



The commits are stacked so that **each one type-checks, builds, and runs
on its own** — you can check out any commit and use the GCP page in the
state that commit describes:

1. c5f550d88 — **GCP page, runnable end-to-end minus the drawers**: type
definitions, constants, DTO→UI account mapping, logo, and the wiring
into the shared integration UI (`IntegrationDetailPage` routes the `gcp`
id to `CloudIntegration`, `AccountActions` maps GCP accounts,
`ServiceDetails` handles the GCP service config shape, remove-account
copy). Also narrows the Azure config checks to `resource_groups`, since
the widened config union means `deployment_region` no longer uniquely
identifies Azure. At this commit the GCP page renders and lists
accounts/services; the add-/edit-account buttons are intentionally
no-ops.
2. dc4b4c3d0 — **Add-account drawer**: the cloud account setup drawer
(flow selector, connection secret fields, field validators, and the
`useCloudAccountSetupDrawer` orchestration hook), wired into
`AccountActions`. Secret fields use the shared periscope `CopyButton`,
which gains an optional `onCopy` callback for the toast.
3. 4f45b3a69 — **Edit-account drawer**: the account settings drawer for
an already-connected GCP account, wired into `AccountActions`.
4. 4f3fe7ec6 — **Fix connection-status polling**:
`isOneClickIntegration` only listed AWS and Azure, so
`IntegrationDetailPage` treated GCP as a legacy integration and polled
`GET /integrations/gcp/connection_status` every 5s via
`useGetIntegrationStatus`. GCP has no such legacy status endpoint, so
it's added to the allowlist to disable the poll — bringing it to parity
with AWS/Azure.


#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change.




https://github.com/user-attachments/assets/7bd5f9d3-85ff-452d-85f4-47eb95c0cce6



#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/39/views/11?filterQuery=assignee%3Atewarig&pane=issue&itemId=212300300&issue=SigNoz%7Cengineering-pod%7C5684

N/A

---

###  Change Type
_Select all that apply_

- [x]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: 
Will add tests in follow up

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Scoped to the Cloud Integrations feature. Shared
components (`AccountActions`, `ServiceDetails`,
`RemoveIntegrationAccount`) gained GCP branches guarded by `type ===
GCP_SERVICES`, so AWS/Azure paths are unaffected.
- Potential regressions: The Azure config-narrowing change is the only
edit to existing Azure behavior; verified the Azure edit modal still
type-checks and reads `resource_groups`/`deployment_region` correctly.
- Rollback plan: Revert the PR — GCP is additive and gated by provider
type, so removal leaves AWS/Azure untouched.

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | Cloud |
| Change Type | Feature |
| Description | Added Google Cloud Platform cloud integration: connect a
GCP account and manage monitored projects from the Integrations UI. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [x] Breaking changes documented (none — additive)
- [x] Backward compatibility considered

---

## 👀 Notes for Reviewers

> The PR is best reviewed commit by commit — each commit is
independently green (tsc, lint, build) and runnable, so you can check
out any of the three and exercise the GCP page at that stage.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-10 10:18:54 +00:00
Pandey
cd8346a91a test(callbackauthn): cover the google authn flow end to end (#12486)
#### Description

- Adds end-to-end coverage for the google authn flow
(`callbackauthn/04_google.py`): happy-path login, hd-claim mismatch
rejection, unverified-email rejection + `insecureSkipEmailVerified`
opt-in, and roleMapping defaultRole.
- The google callback authn hardcodes `https://accounts.google.com` as
its issuer and fully verifies the RS256 id_token, so a wiremock
container impersonates Google: it joins the test network under the
`accounts.google.com` alias and serves HTTPS with a certificate issued
by a new integration CA (`tests/fixtures/tls.py`), which every signoz
container now trusts via `SSL_CERT_FILE`.
- Stubs (discovery, auto-approving authorize, token, JWKS) are installed
per test via the existing `make_http_mocks` fixture with a pre-signed
id_token for the identity under test; one session-scoped RSA key signs
all tokens.
2026-08-10 08:35:58 +00:00
Vikrant Gupta
db5f4b4cd5 feat(user): add v2 reset password endpoint (#12489)
#### Description

- `POST /api/v1/resetPassword` was the last password endpoint with no v2
equivalent. Adds `POST /api/v2/factor_password/reset`, next to the
existing `/factor_password/forgot`, so the whole recovery flow lives
under one namespace.
- v1 keeps working and is now marked deprecated. Both routes share the
same handler, so behaviour is identical.
- A malformed request body now returns a structured 400 instead of a
500. This applies to v1 too, since the handler is shared.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information


- The generated frontend client is included because CI re-runs `pnpm
generate:api` and fails on drift. The UI still calls v1; moving it to
the new `useResetPassword` hook is a separate PR to keep review
ownership split.
- Not fixed here: a reset doesn't revoke existing sessions, though a
voluntary password change does. Worth its own ticket.
2026-08-10 08:27:46 +00:00
Aditya Singh
3bd9b2a96a feat(log-details): update log details header (#12310)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

Slice 1 of the log-details drawer revamp, a reworked drawer **header**,
gated behind the new `isLogDetailsV2` flag (ships off). Highlights and
the DataViewer land in
the following stacked PRs.

 **Change points**

- Added a new **Log Details Header** with:
  - A formatted timestamp based on the user's timezone
  - A ⋯ menu with **Copy log** and **Copy link to log**
  - Up/down arrows to move between logs
  - An optional **Open in Explorer** button
 
- Moved the log navigation logic into a separate `useLogNavigation` hook
- Updated the Log Details drawer:
- Shows the new header when the feature flag is on (otherwise keeps the
old title)
- Replaced the WARN/ERROR divider with `LogStateIndicator`, which shows
colors for all log levels
- Moved the copy actions into the ⋯ menu and removed the old inline copy
button in V2
- Uses the new navigation hook for both keyboard shortcuts and header
arrows

- Updated the copy link handler:
- `onLogCopy` now accepts an optional click event, so it can be called
from the ⋯ menu
- Moved the "Copied to clipboard" toast from the top-right to the
bottom-right

- Everything is gated behind feature flag for now


#### Screenshots / Screen Recordings (if applicable)


https://github.com/user-attachments/assets/4a73e299-4715-4feb-81e3-762fdc3d0757




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

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🐛 Bug Context
> Required if this PR fixes a bug

#### Root Cause
> What caused the issue?  
> Regression, faulty assumption, edge case, refactor, etc.

#### Fix Strategy
> How does this PR address the root cause?

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated:
- Manual verification:
- Edge cases covered:

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius:
- Potential regressions:
- Rollback plan:

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature / Bug Fix / Maintenance |
| Description | User-facing summary |

---

### 📋 Checklist
- [ ] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered

---

## 👀 Notes for Reviewers

<!-- Anything reviewers should keep in mind while reviewing -->

---
2026-08-10 06:54:54 +00:00
Pandey
c4e6487e0d chore(frontend): remove the loginPrecheck mock for a removed endpoint (#12485)
#### Description

- Removes the msw mock for `/api/v1/loginPrecheck` — the endpoint no
longer exists in the backend and nothing in the frontend calls it
anymore; the login flow runs on `/api/v2/sessions/context`.
2026-08-10 06:33:29 +00:00
Vikrant Gupta
40aa322cc3 feat(invite): accept multiple roles per invited member (#12476)
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
#### Description

- Invited members can now be given more than one role. The picker was
single-select even though `POST /api/v2/users` has accepted a list of
roles since custom roles landed.
- Frontend only — nothing changed on the backend, the grant chain was
already multi-role.
- Onboarding analytics now emits `teamMembers[].roles` as a list,
replacing the singular `role` key.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2920


#### Screenshots / Screen Recordings

#### Members Page

https://github.com/user-attachments/assets/8b556549-789c-4ddf-b4af-5994eccc75f3


#### Onboarding Flow

https://github.com/user-attachments/assets/a6dde5d3-e9b4-48c1-965f-352bb0a6a89f



#### Additional Information

- A row still requires at least one role to be considered valid.
- Existing invite tests moved to `findByTitle` for role options,
matching how `EditMemberDrawer` already drives the multi-select.
2026-08-08 20:59:52 +00:00
Vikrant Gupta
2a2b393146 chore(user): remove the deprecated user by id endpoints (#12474)
#### Description

- Removes `GET`, `PUT` and `DELETE /api/v1/user/{id}` — all deprecated
and superseded by `/api/v2/users/{id}`, which the frontend already uses.
- Drops the dead code this leaves behind: the `SelfAccess` middleware
and `Claims.IsSelfAccess` (no callers left), the deprecated update
setters, and three `DeprecatedUser` helpers.
- Points the integration tests that deleted users at `DELETE
/api/v2/users/{id}`.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information

- Behaviour change: the removed `GET`/`PUT` were `SelfAccess`, the v2
equivalents are `AdminAccess`. Self-serve reads and updates go through
`/api/v2/users/me`, which is what the UI already calls — but worth a
second pair of eyes.
- `DELETE /api/v1/user/{id}` was the most widely reached of the three.
Please confirm nothing external (zeus) still calls it before merging.
- OpenAPI spec and the generated frontend client are regenerated, not
hand-edited.
2026-08-08 20:51:14 +00:00
Vikrant Gupta
b904aca1a8 chore(user): remove the deprecated bulk invite endpoint (#12473)
#### Description

- Removes `POST /api/v1/invite/bulk` — already deprecated, superseded by
`POST /api/v1/invite`, and no callers left.
- `Setter.CreateBulkInvite` stays; `CreateInvite` still delegates to it
for the single-invite case.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information

- OpenAPI spec and the generated frontend client are regenerated, not
hand-edited.
- The `integrationci / fmtlint` failure here is not from this PR — `make
py-lint` is broken on `main`. Fixed separately in #12475; this PR needs
that merged (or a rebase on it) to go green.
- First of three PRs splitting a v1 user-API cleanup. The other two also
regenerate the spec and generated client, so whichever merges second
needs the generators re-run.
2026-08-08 20:24:55 +00:00
Vikrant Gupta
530c050b71 fix(tests): call Metrics.load_from_file directly in the pods filter test (#12475)
#### Description

- `make py-lint` is failing on `main` with `F821 Undefined name
_load_pods_metrics` at `inframonitoring/02_pods.py:471`, which blocks
every open PR.
- `test_pods_filter_pagination_and_ordering` calls a helper that no
longer exists. Replaced with
`Metrics.load_from_file(get_testdata_file_path(...))`, matching the two
other tests that seed `pods_phases.jsonl`.

#### Additional Information

- How it broke: #12460 replaced the module-level `_load_pods_metrics`
with a `load_pods_metrics` fixture, then #12462 dropped that fixture in
favour of calling `Metrics.load_from_file` directly. #12278 branched
before either landed and merged after, re-introducing one call to the
long-gone helper. Git merged cleanly because the branches touched
different lines, so nothing flagged it.
- No behaviour change: the broken call passed no `start_time`, so no
placeholder substitution happened; `load_from_file` with
`label_substitutions=None` does the same earliest-to-`base_time` rebase.
The replacement is byte-identical to the two sibling call sites for the
same dataset.
2026-08-08 19:35:18 +00:00
Swapnil Nakade
bfa174ce2a refactor: removing logs support for gcp integration services (#12471)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
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
Disabling logs support for all GCP integration services.

Reasons:
1. googlecloudpubsubpush receiver needs to reach Alpha stability, thus
not included in contrib build -> can't suggest for logs
2. signoz otel collector includes this receiver in the build but it
needs upgrade to v0.158.0 for a metrics related
[fix](https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/49826)
-> needs more testing hence can't suggest either

Decision was taken to go ahead without logs for now - follow [ticket
here](https://github.com/SigNoz/platform-pod/issues/2901) for details.


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Contributes to 
https://github.com/SigNoz/platform-pod/issues/2901

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
The explanation above should be enough

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-08 11:03:25 +00:00
Nikhil Mantri
85bf5ce644 feat(infra-monitoring): filter by pod status (#12278)
## Pull Request

---

### 📄 Summary

Adds a `filterByPodStatus` secondary filter to the v2 infra-monitoring
list APIs (pods, nodes, namespaces, clusters, deployments, statefulsets,
jobs, daemonsets).

Pod status is a derived kubectl-style value (`k8s.pod.phase` + status
reasons, resolved via `argMax`), not a real label, so it can't go
through the normal query-builder filter. This PR resolves the full-scope
status keyset up-front and intersects it with the metadata + ranked
groups, keeping `total` and pagination correct.

- Multi-select: the field is an array, pushed down as `WHERE
lower(display_status) IN (...)` (OR within status, AND with the
attribute filter).
- When the optional status metrics were never ingested, the endpoint
returns a non-blocking warning + empty page instead of silently
filtering everything out.

#### Screenshots / Screen Recordings (if applicable)

N/A — backend + generated FE API types only; the UI is a separate
change.

#### Issues closed by this PR

Part of SigNoz/engineering-pod#5778.

---

###  Change Type

- [x]  Feature
- [ ] 🐛 Bug fix
- [x] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🐛 Bug Context

N/A — not a bug fix.

---

### 🧪 Testing Strategy

- Tests added/updated:
- Unit test for the status push-down (`applyPodStatusFilter`, built with
go-sqlbuilder).
- Integration tests across all 8 entity APIs: list mode, grouped mode,
validation, missing-metric warning, and multi-select union.
- Manual verification: smoke-tested against staging data (single, multi,
and grouped filters).
- Edge cases covered: missing status metric → warning + empty; grouped
mode keeps a group if ≥1 pod matches; multi-select returns the union of
the selected statuses.

---

### ⚠️ Risk & Impact Assessment

- Blast radius: v2 infra-monitoring list endpoints only.
- Potential regressions: none when the filter is unset (empty = off,
fully additive). When set, an extra status query runs; it is gated
behind the filter being present.
- Rollback plan: revert the PR — no schema or data migrations involved.

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | OSS, Cloud, Enterprise |
| Change Type | Feature |
| Description | v2 infra-monitoring lists can now be filtered by pod
status (multi-select). |

---

### 📋 Checklist

- [x] Tests added or explicitly not required
- [x] Manually tested
- [x] Breaking changes documented
- [x] Backward compatibility considered

---

## 👀 Notes for Reviewers

- `filterByPodStatus` is optional and additive — no change to existing
responses when omitted.
- The status keyset is resolved once at full scope, then intersected —
this is what keeps `total`/pagination correct despite status being a
post-aggregation value.
- OpenAPI spec + FE API types are regenerated (scalar → array); no
hand-written FE.
2026-08-08 10:56:48 +00:00
Nikhil Mantri
a654f648ee fix(infra-monitoring): the count query's alias collision when groupBy overlaps counted attrs (#12451)
## Pull Request

---

### 📄 Summary

The infra-monitoring v2 clusters/namespaces list APIs 500 with
ClickHouse error 179 (`MULTIPLE_EXPRESSIONS_FOR_ALIAS`) when the request
groups by an attribute that is also a counted resource attribute (e.g.
clusters grouped by `k8s.node.name` or `k8s.namespace.name`, namespaces
grouped by `k8s.deployment.name`).

Root cause: `getPerGroupDistinctCounts` aliases each `uniqExactIf(...)`
count column with the bare attr key, which collides with the groupBy
column alias for the same key. Fix: alias count columns as
`__count_<attr>`. Row scanning is positional and the result map is keyed
in Go from `attrNames`, so nothing downstream changes.

Integration tests: clusters API grouped by `k8s.namespace.name` and
namespaces API grouped by `k8s.deployment.name`, with exact per-group
`counts` assertions (identity-tuple semantics). Both reproduce the 500
on the pre-fix build and pass on the fixed build.

#### Issues closed by this PR

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

###  Change Type
_Select all that apply_

- [ ]  Feature
- [x] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy

- Tests added/updated: Yes — integration (`04_namespaces.py`,
`05_clusters.py`): new groupBy-on-counted-attr cases + per-group
`counts` assertions on existing cases
- Manual verification: Yes — replayed the failing production query with
the fixed aliasing against ClickHouse
- Edge cases covered: groupBy overlapping a counted attr; same-named
deployment across namespaces counted as distinct entities

---

### ⚠️ Risk & Impact Assessment

- Blast radius: Infrastructure Monitoring — clusters & namespaces list
APIs (counts query)
- Potential regressions: None — SQL alias rename only; scanning is
positional and result map keys are unchanged
- Rollback plan: Revert this commit

---

### 📝 Changelog

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | Fixed a 500 error in Infrastructure Monitoring
clusters/namespaces APIs when grouping by an attribute that is also part
of the resource counts (e.g. node, namespace, or deployment name). |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [x] Backward compatibility considered
2026-08-08 10:51:40 +00:00
140 changed files with 7331 additions and 1915 deletions

View File

@@ -4212,6 +4212,21 @@ components:
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterFilter:
properties:
expression:
type: string
filterByNodeReadiness:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
nullable: true
type: array
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4349,6 +4364,16 @@ components:
- containerCannotRun
- unknown
type: object
InframonitoringtypesContainerFilter:
properties:
expression:
type: string
filterByContainerStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
nullable: true
type: array
type: object
InframonitoringtypesContainerReady:
enum:
- ready
@@ -4448,6 +4473,16 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -4520,6 +4555,16 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDeploymentFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesDeploymentRecord:
properties:
availablePods:
@@ -4661,6 +4706,16 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesJobFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesJobRecord:
properties:
activePods:
@@ -4784,6 +4839,16 @@ components:
- message
- documentationLink
type: object
InframonitoringtypesNamespaceFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
@@ -4865,6 +4930,21 @@ components:
- ready
- notReady
type: object
InframonitoringtypesNodeFilter:
properties:
expression:
type: string
filterByNodeReadiness:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
nullable: true
type: array
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
@@ -4981,6 +5061,16 @@ components:
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesPodRecord:
properties:
meta:
@@ -5080,7 +5170,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesClusterFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5106,7 +5196,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesContainerFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5132,7 +5222,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesDaemonSetFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5158,7 +5248,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesDeploymentFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5210,7 +5300,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesJobFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5236,7 +5326,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesNamespaceFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5262,7 +5352,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesNodeFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5288,7 +5378,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesPodFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5314,7 +5404,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
$ref: '#/components/schemas/InframonitoringtypesStatefulSetFilter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5365,6 +5455,16 @@ components:
- list
- grouped_list
type: string
InframonitoringtypesStatefulSetFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesStatefulSetRecord:
properties:
currentPods:
@@ -8838,15 +8938,6 @@ components:
required:
- id
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
items:
$ref: '#/components/schemas/TypesPostableInvite'
type: array
required:
- invites
type: object
TypesPostableForgotPassword:
properties:
email:
@@ -11454,57 +11545,6 @@ paths:
summary: Create invite
tags:
- users
/api/v1/invite/bulk:
post:
deprecated: true
description: This endpoint creates a bulk invite for a user
operationId: CreateBulkInvite
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableBulkInviteRequest'
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create bulk invite
tags:
- users
/api/v1/llm_pricing_rules:
get:
deprecated: false
@@ -12158,9 +12198,9 @@ paths:
- dashboard
/api/v1/resetPassword:
post:
deprecated: false
deprecated: true
description: This endpoint resets the password by token
operationId: ResetPassword
operationId: ResetPasswordDeprecated
requestBody:
content:
application/json:
@@ -14428,177 +14468,6 @@ paths:
summary: List users
tags:
- users
/api/v1/user/{id}:
delete:
deprecated: true
description: This endpoint deletes the user by id
operationId: DeleteUserDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete user
tags:
- users
get:
deprecated: true
description: This endpoint returns the user by id
operationId: GetUserDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesDeprecatedUser'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get user
tags:
- users
put:
deprecated: true
description: This endpoint updates the user by id
operationId: UpdateUserDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesDeprecatedUser'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesDeprecatedUser'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update user
tags:
- users
/api/v1/user/me:
get:
deprecated: true
@@ -15698,6 +15567,41 @@ paths:
summary: Forgot password
tags:
- users
/api/v2/factor_password/reset:
post:
deprecated: false
description: This endpoint resets the password using a single use reset password
token
operationId: ResetPassword
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableResetPassword'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Reset password
tags:
- users
/api/v2/features:
get:
deprecated: false

View File

@@ -349,7 +349,7 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`). How to model the sum type itself is covered in [types.md](types.md#sum-types-the-kindspec-envelope) — this section is only about its schema.
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.

View File

@@ -99,6 +99,69 @@ Each flavor exists for a concrete reason:
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
## Sum types: the kind/spec envelope
When a domain type is a *sum type* — exactly one of several variants, selected by a discriminator — model it as an envelope with a `kind` and a `spec`:
```go
type FooConfig struct {
Kind FooKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
```
```json
{ "kind": "bar", "spec": { "url": "...", "timeout": "30s" } }
```
`Kind` is a `valuer.String` enum implementing `Enum()`; `Spec` holds exactly one concrete variant type (`BarSpec`, `BazSpec`, …). `RuleThresholdData` and `EvaluationEnvelope` in `pkg/types/ruletypes/` are the canonical in-tree examples; the dashboard panel/query/variable plugins in `pkg/types/dashboardtypes/` are the same pattern behind generics. (`QueryEnvelope` in querybuildertypes uses `type` as the discriminator key for historical reasons; new envelopes use `kind`.)
### The envelope goes at the point of variance, not the resource root
Put the envelope on the field that actually varies. The resource root is almost never a sum type — a `Foo` has a `name` and an `enabled` flag regardless of which kind it is configured with; only its configuration varies, so the envelope is the `config` field:
```json
{ "name": "my-foo", "enabled": true, "config": { "kind": "bar", "spec": { "...": "..." } } }
```
Hoisting `kind`/`spec` to the root would turn the whole resource into a `oneOf`: every flavor (`PostableFoo`, `UpdatableFoo`, `GettableFoo`) then needs one variant schema per kind, each repeating the common fields; every new common field has to be added to all of them; and generated clients get unions of large objects instead of one small union that narrows on `config.kind`. A root-level `kind` also collides with the resource-model meaning of the word — root `kind` conventionally answers "what resource is this" (`Dashboard`), never "which flavor of config does it hold".
The existing domains already follow this placement:
- **Rules** — plain root; envelopes on the varying fields: `thresholds: {kind, spec}` and `evaluation: {kind, spec}`.
- **Dashboards** — metadata at the root plus one typed `spec`; the unions sit deep inside, at each panel/query/variable plugin (`{kind, spec}` in `perses_plugin_wrappers.go`).
- **Saved views** — root `{schemaVersion, spec}`, where `spec` is a *versioning* envelope holding one fixed type, not a union; the unions are inside it (`spec.queries: [{type, spec}]`). Same word, different job — a versioned body is not a discriminated union.
### Why this tagging style
Of the union encodings in common use, the envelope is the *adjacently tagged* one — tag and payload side by side. Variant payloads stay collision-free, and each kind maps to a named wrapper schema that carries the discriminator, which is exactly what OpenAPI generators need. The alternatives lose on those points: *internally tagged* (`{"kind": "bar", ...fields flattened}`) mixes common and variant fields, admits cross-variant key collisions, and forces every variant schema to redeclare the discriminator; *sibling optional fields* (`{"kind": "bar", "barConfig": {}, "bazConfig": {}}`) is the anti-pattern the first rule below exists to prevent.
The rules that make the envelope work:
- **Never model variants as sibling fields.** A struct with `Bar *BarSpec`, `Baz *BazSpec` next to a discriminator cannot be expressed as an OpenAPI discriminated union, forces nilability checks on every consumer, and silently admits contradictory payloads (kind=bar with a baz spec). The chosen variant *is* the payload.
- **The envelope owns `UnmarshalJSON`.** Decode `kind` first, then switch on it to decode and validate the matching concrete type into `Spec`. Unknown kinds and missing specs are rejected at the boundary:
```go
func (typ *FooConfig) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
// ... unmarshal raw, decode raw["kind"] ...
switch kind {
case FooKindBar:
spec := BarSpec{}
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return err
}
typ.Spec = spec
// ... one case per kind, default rejects ...
}
typ.Kind = kind
return nil
}
```
- **Consumers type-assert on `Spec`** (`config.Spec.(BarSpec)`) after switching on `Kind`. If assertion sites multiply, add typed accessors on the envelope (see `EvaluationEnvelope.GetEvaluation()`).
- **OpenAPI needs one unexported variant struct per kind** (`fooConfigBar{Kind; Spec BarSpec}`), exposed via `JSONSchemaOneOf()` and mapped via `PrepareJSONSchema` with the `x-signoz-discriminator` extension. The schema mechanics are covered in [handler.md](handler.md#oneof-with-a-discriminator).
- **A legacy persisted shape gets a data migration or a `StorableX`.** When rows were written before the envelope existed, prefer an idempotent `sqlmigration` that rewrites them into the new shape, so the storable type simply nests the envelope. Only when the old shape must keep being written (external writers, rollback windows) keep it in a storable twin and convert at the type boundary.
## Conventions that tie the flavors together
- **Conversions** use either a `New<Output>From<Input>` constructor — e.g. `NewChannelFromReceiver`, `NewGettableAuthDomainFromAuthDomain` — or a receiver-style `ToY()` method. Both forms coexist in the codebase; use whichever fits the call site.
@@ -139,6 +202,8 @@ Both are optional. Do not introduce them if `PostableX` already covers the case.
- Every domain package defines the core type `X`. Only `X` is mandatory.
- Add `PostableX` / `GettableX` / `UpdatableX` / `StorableX` one at a time, only when the shape actually diverges from `X`.
- Model sum types as a `{kind, spec}` envelope with a validating `UnmarshalJSON` — never as sibling variant fields next to a discriminator.
- The envelope goes on the field that varies, never at the resource root — common fields stay on the resource, outside the union.
- Domain logic lives on `X`, not on the flavor types.
- Conversions can be a `New<Output>From<Input>` constructor or a receiver-style `ToY()` method — pick whichever reads best at the call site.
- Use a type alias when two shapes are truly identical.

View File

@@ -527,6 +527,13 @@ const routes: AppRoutes[] = [
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,

View File

@@ -5648,6 +5648,47 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesClusterFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[] | null;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
@@ -5923,21 +5964,6 @@ export interface InframonitoringtypesContainerCountsByStatusDTO {
waiting: number;
}
export enum InframonitoringtypesContainerReadyDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesContainerStatusDTO {
running = 'running',
waiting = 'waiting',
@@ -5954,6 +5980,32 @@ export enum InframonitoringtypesContainerStatusDTO {
unknown = 'unknown',
no_data = 'no_data',
}
export interface InframonitoringtypesContainerFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[] | null;
}
export enum InframonitoringtypesContainerReadyDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
@@ -6025,6 +6077,17 @@ export interface InframonitoringtypesContainersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesDaemonSetFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6110,6 +6173,17 @@ export interface InframonitoringtypesDaemonSetsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesDeploymentFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6272,6 +6346,17 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesJobFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6357,6 +6442,17 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesNamespaceFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
@@ -6433,11 +6529,21 @@ export interface InframonitoringtypesNamespacesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
export interface InframonitoringtypesNodeFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[] | null;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6499,6 +6605,17 @@ export interface InframonitoringtypesNodesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPodFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6509,27 +6626,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6606,7 +6702,7 @@ export interface InframonitoringtypesPostableClustersDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesClusterFilterDTO;
/**
* @type array,null
*/
@@ -6633,7 +6729,7 @@ export interface InframonitoringtypesPostableContainersDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesContainerFilterDTO;
/**
* @type array,null
*/
@@ -6660,7 +6756,7 @@ export interface InframonitoringtypesPostableDaemonSetsDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesDaemonSetFilterDTO;
/**
* @type array,null
*/
@@ -6687,7 +6783,7 @@ export interface InframonitoringtypesPostableDeploymentsDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesDeploymentFilterDTO;
/**
* @type array,null
*/
@@ -6741,7 +6837,7 @@ export interface InframonitoringtypesPostableJobsDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesJobFilterDTO;
/**
* @type array,null
*/
@@ -6768,7 +6864,7 @@ export interface InframonitoringtypesPostableNamespacesDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesNamespaceFilterDTO;
/**
* @type array,null
*/
@@ -6795,7 +6891,7 @@ export interface InframonitoringtypesPostableNodesDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesNodeFilterDTO;
/**
* @type array,null
*/
@@ -6822,7 +6918,7 @@ export interface InframonitoringtypesPostablePodsDTO {
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesPodFilterDTO;
/**
* @type array,null
*/
@@ -6843,13 +6939,24 @@ export interface InframonitoringtypesPostablePodsDTO {
start: number;
}
export interface InframonitoringtypesStatefulSetFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export interface InframonitoringtypesPostableStatefulSetsDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
filter?: InframonitoringtypesStatefulSetFilterDTO;
/**
* @type array,null
*/
@@ -9950,6 +10057,21 @@ export interface TypesOrganizationDTO {
updatedAt?: string;
}
export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
frontendBaseURL?: string;
/**
* @type string
*/
orgId: string;
}
export interface TypesPostableInviteDTO {
/**
* @type string
@@ -9969,28 +10091,6 @@ export interface TypesPostableInviteDTO {
role?: string;
}
export interface TypesPostableBulkInviteRequestDTO {
/**
* @type array
*/
invites: TypesPostableInviteDTO[];
}
export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
frontendBaseURL?: string;
/**
* @type string
*/
orgId: string;
}
export interface TypesPostableResetPasswordDTO {
/**
* @type string
@@ -11101,31 +11201,6 @@ export type ListUsersDeprecated200 = {
status: string;
};
export type DeleteUserDeprecatedPathParameters = {
id: string;
};
export type GetUserDeprecatedPathParameters = {
id: string;
};
export type GetUserDeprecated200 = {
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
status: string;
};
export type UpdateUserDeprecatedPathParameters = {
id: string;
};
export type UpdateUserDeprecated200 = {
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
status: string;
};
export type GetMyUserDeprecated200 = {
data: TypesDeprecatedUserDTO;
/**

View File

@@ -25,7 +25,6 @@ import type {
CreateResetPasswordTokenPathParameters,
CreateUser201,
CreateUserRole201,
DeleteUserDeprecatedPathParameters,
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
@@ -37,8 +36,6 @@ import type {
GetRolesByUserID200,
GetRolesByUserIDPathParameters,
GetUser200,
GetUserDeprecated200,
GetUserDeprecatedPathParameters,
GetUserPathParameters,
GetUserRole200,
GetUserRolePathParameters,
@@ -50,16 +47,12 @@ import type {
RenderErrorResponseDTO,
SetRoleByUserIDPathParameters,
TypesChangePasswordRequestDTO,
TypesDeprecatedUserDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserDeprecated200,
UpdateUserDeprecatedPathParameters,
UpdateUserPathParameters,
} from '../sigNoz.schemas';
@@ -260,96 +253,12 @@ export const useCreateInvite = <
> => {
return useMutation(getCreateInviteMutationOptions(options));
};
/**
* This endpoint creates a bulk invite for a user
* @deprecated
* @summary Create bulk invite
*/
export const createBulkInvite = (
typesPostableBulkInviteRequestDTO?: BodyType<TypesPostableBulkInviteRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/invite/bulk`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableBulkInviteRequestDTO,
signal,
});
};
export const getCreateBulkInviteMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data?: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data?: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
const mutationKey = ['createBulkInvite'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createBulkInvite>>,
{ data?: BodyType<TypesPostableBulkInviteRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return createBulkInvite(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateBulkInviteMutationResult = NonNullable<
Awaited<ReturnType<typeof createBulkInvite>>
>;
export type CreateBulkInviteMutationBody =
| BodyType<TypesPostableBulkInviteRequestDTO>
| undefined;
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create bulk invite
*/
export const useCreateBulkInvite = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data?: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data?: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
return useMutation(getCreateBulkInviteMutationOptions(options));
};
/**
* This endpoint resets the password by token
* @deprecated
* @summary Reset password
*/
export const resetPassword = (
export const resetPasswordDeprecated = (
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
signal?: AbortSignal,
) => {
@@ -362,23 +271,23 @@ export const resetPassword = (
});
};
export const getResetPasswordMutationOptions = <
export const getResetPasswordDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
const mutationKey = ['resetPassword'];
const mutationKey = ['resetPasswordDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -388,45 +297,47 @@ export const getResetPasswordMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof resetPassword>>,
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
{ data?: BodyType<TypesPostableResetPasswordDTO> }
> = (props) => {
const { data } = props ?? {};
return resetPassword(data);
return resetPasswordDeprecated(data);
};
return { mutationFn, ...mutationOptions };
};
export type ResetPasswordMutationResult = NonNullable<
Awaited<ReturnType<typeof resetPassword>>
export type ResetPasswordDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof resetPasswordDeprecated>>
>;
export type ResetPasswordMutationBody =
export type ResetPasswordDeprecatedMutationBody =
| BodyType<TypesPostableResetPasswordDTO>
| undefined;
export type ResetPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
export type ResetPasswordDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Reset password
*/
export const useResetPassword = <
export const useResetPasswordDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof resetPassword>>,
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
return useMutation(getResetPasswordMutationOptions(options));
return useMutation(getResetPasswordDeprecatedMutationOptions(options));
};
/**
* This endpoint lists all users
@@ -515,295 +426,6 @@ export const invalidateListUsersDeprecated = async (
return queryClient;
};
/**
* This endpoint deletes the user by id
* @deprecated
* @summary Delete user
*/
export const deleteUserDeprecated = (
{ id }: DeleteUserDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/user/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteUserDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserDeprecated>>,
TError,
{ pathParams: DeleteUserDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteUserDeprecated>>,
TError,
{ pathParams: DeleteUserDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteUserDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteUserDeprecated>>,
{ pathParams: DeleteUserDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteUserDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteUserDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteUserDeprecated>>
>;
export type DeleteUserDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete user
*/
export const useDeleteUserDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserDeprecated>>,
TError,
{ pathParams: DeleteUserDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteUserDeprecated>>,
TError,
{ pathParams: DeleteUserDeprecatedPathParameters },
TContext
> => {
return useMutation(getDeleteUserDeprecatedMutationOptions(options));
};
/**
* This endpoint returns the user by id
* @deprecated
* @summary Get user
*/
export const getUserDeprecated = (
{ id }: GetUserDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetUserDeprecated200>({
url: `/api/v1/user/${id}`,
method: 'GET',
signal,
});
};
export const getGetUserDeprecatedQueryKey = ({
id,
}: GetUserDeprecatedPathParameters) => {
return [`/api/v1/user/${id}`] as const;
};
export const getGetUserDeprecatedQueryOptions = <
TData = Awaited<ReturnType<typeof getUserDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserDeprecatedPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserDeprecated>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetUserDeprecatedQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getUserDeprecated>>
> = ({ signal }) => getUserDeprecated({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserDeprecated>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserDeprecatedQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserDeprecated>>
>;
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get user
*/
export function useGetUserDeprecated<
TData = Awaited<ReturnType<typeof getUserDeprecated>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserDeprecatedPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserDeprecated>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserDeprecatedQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @deprecated
* @summary Get user
*/
export const invalidateGetUserDeprecated = async (
queryClient: QueryClient,
{ id }: GetUserDeprecatedPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetUserDeprecatedQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates the user by id
* @deprecated
* @summary Update user
*/
export const updateUserDeprecated = (
{ id }: UpdateUserDeprecatedPathParameters,
typesDeprecatedUserDTO?: BodyType<TypesDeprecatedUserDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateUserDeprecated200>({
url: `/api/v1/user/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: typesDeprecatedUserDTO,
signal,
});
};
export const getUpdateUserDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUserDeprecated>>,
TError,
{
pathParams: UpdateUserDeprecatedPathParameters;
data?: BodyType<TypesDeprecatedUserDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateUserDeprecated>>,
TError,
{
pathParams: UpdateUserDeprecatedPathParameters;
data?: BodyType<TypesDeprecatedUserDTO>;
},
TContext
> => {
const mutationKey = ['updateUserDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateUserDeprecated>>,
{
pathParams: UpdateUserDeprecatedPathParameters;
data?: BodyType<TypesDeprecatedUserDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateUserDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateUserDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof updateUserDeprecated>>
>;
export type UpdateUserDeprecatedMutationBody =
| BodyType<TypesDeprecatedUserDTO>
| undefined;
export type UpdateUserDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Update user
*/
export const useUpdateUserDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUserDeprecated>>,
TError,
{
pathParams: UpdateUserDeprecatedPathParameters;
data?: BodyType<TypesDeprecatedUserDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateUserDeprecated>>,
TError,
{
pathParams: UpdateUserDeprecatedPathParameters;
data?: BodyType<TypesDeprecatedUserDTO>;
},
TContext
> => {
return useMutation(getUpdateUserDeprecatedMutationOptions(options));
};
/**
* This endpoint returns the user I belong to
* @deprecated
@@ -974,6 +596,89 @@ export const useForgotPassword = <
> => {
return useMutation(getForgotPasswordMutationOptions(options));
};
/**
* This endpoint resets the password using a single use reset password token
* @summary Reset password
*/
export const resetPassword = (
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/factor_password/reset`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableResetPasswordDTO,
signal,
});
};
export const getResetPasswordMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
const mutationKey = ['resetPassword'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof resetPassword>>,
{ data?: BodyType<TypesPostableResetPasswordDTO> }
> = (props) => {
const { data } = props ?? {};
return resetPassword(data);
};
return { mutationFn, ...mutationOptions };
};
export type ResetPasswordMutationResult = NonNullable<
Awaited<ReturnType<typeof resetPassword>>
>;
export type ResetPasswordMutationBody =
| BodyType<TypesPostableResetPasswordDTO>
| undefined;
export type ResetPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Reset password
*/
export const useResetPassword = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof resetPassword>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof resetPassword>>,
TError,
{ data?: BodyType<TypesPostableResetPasswordDTO> },
TContext
> => {
return useMutation(getResetPasswordMutationOptions(options));
};
/**
* This endpoint verifies whether a reset password token exists and is not expired
* @summary Verify a reset password token

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UsersProps } from 'types/api/user/inviteUsers';
/**
* @deprecated Use the generated `useCreateBulkInvite` hook (or `createBulkInvite` fetcher) from
* `api/generated/services/users` 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.
*/
const inviteUsers = async (
users: UsersProps,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.post(`/invite/bulk`, users);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default inviteUsers;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>

After

Width:  |  Height:  |  Size: 805 B

View File

@@ -23,6 +23,13 @@
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.cloud-service-data-collected-table-heading-info {
display: inline-flex;
align-items: center;
color: var(--l3-foreground);
cursor: help;
}
}
.cloud-service-data-collected-table-logs {
@@ -32,3 +39,9 @@
}
}
}
.cloud-service-data-collected-table-tooltip {
max-width: 280px;
white-space: normal;
word-break: break-word;
}

View File

@@ -3,16 +3,19 @@ import {
CloudintegrationtypesCollectedLogAttributeDTO,
CloudintegrationtypesCollectedMetricDTO,
} from 'api/generated/services/sigNoz.schemas';
import { BarChart, ScrollText } from '@signozhq/icons';
import { BarChart, Info, ScrollText } from '@signozhq/icons';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import './CloudServiceDataCollected.styles.scss';
function CloudServiceDataCollected({
logsData,
metricsData,
metricsInfoTooltip,
}: {
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
metricsInfoTooltip?: string;
}): JSX.Element {
const logsColumns = [
{
@@ -84,6 +87,25 @@ function CloudServiceDataCollected({
<div className="cloud-service-data-collected-table-heading">
<BarChart size={14} />
Metrics
{metricsInfoTooltip && (
<TooltipProvider>
<TooltipSimple
title={metricsInfoTooltip}
side="top"
tooltipContentProps={{
className: 'cloud-service-data-collected-table-tooltip',
}}
>
<span
className="cloud-service-data-collected-table-heading-info"
aria-label="About the metrics listed below"
data-testid="data-collected-metrics-info"
>
<Info size={12} />
</span>
</TooltipSimple>
</TooltipProvider>
)}
</div>
<Table
columns={metricsColumns}
@@ -97,4 +119,8 @@ function CloudServiceDataCollected({
);
}
CloudServiceDataCollected.defaultProps = {
metricsInfoTooltip: undefined,
};
export default CloudServiceDataCollected;

View File

@@ -5,10 +5,9 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useGetRolesByUserID,
useCreateUserRole,
useDeleteUserRole,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
@@ -25,15 +24,14 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useDeleteUserRole: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useCreateUserRole: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
getGetUserQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}`,
],
}));
@@ -194,11 +192,7 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
(useDeleteUserRole as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
@@ -210,7 +204,7 @@ describe('EditMemberDrawer', () => {
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
(useCreateUserRole as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
@@ -312,12 +306,12 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('adding a new role calls setRole without removing existing ones', async () => {
it('adding a new role creates a user role without removing existing ones', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
(useCreateUserRole as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
@@ -334,15 +328,14 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
data: { userId: 'user-1', roleId: managedRoles[1].id },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('deselecting a role calls removeRole with the role id', async () => {
it('deselecting a role deletes the user role by its assignment id', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -361,7 +354,7 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
pathParams: { id: 'ur-1' },
});
expect(onComplete).toHaveBeenCalled();
});

View File

@@ -80,7 +80,7 @@ function InviteMembers({
weight="semibold"
className={styles.headerCellRole}
>
Role
Roles
</Typography.Text>
<div className={styles.headerCellAction} />
</div>
@@ -108,11 +108,10 @@ function InviteMembers({
<div className={styles.cellRole}>
<RolesSelect
mode="single"
value={row.roleId || undefined}
onChange={(roleId): void => updateRole(row.id, roleId)}
placeholder="Select role"
allowClear={false}
mode="multiple"
value={row.roleIds}
onChange={(roleIds): void => updateRole(row.id, roleIds)}
placeholder="Select roles"
id={`invite-role-${row.id}`}
/>
</div>

View File

@@ -68,8 +68,8 @@ describe('InviteMembers - Edge Cases', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
@@ -100,8 +100,8 @@ describe('InviteMembers - Edge Cases', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
@@ -132,17 +132,17 @@ describe('InviteMembers - Edge Cases', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
const viewerElements = screen.getAllByText('Viewer');
const viewerElements = screen.getAllByTitle('Viewer');
await user.click(viewerElements[0]);
const editorOptions = await screen.findAllByText('Editor');
const editorOptions = await screen.findAllByTitle('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await waitFor(() => {
@@ -189,8 +189,8 @@ describe('InviteMembers - Edge Cases', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
const submitBtn = screen.getByTestId('submit-btn');
await user.click(submitBtn);
@@ -226,8 +226,8 @@ describe('InviteMembers - Edge Cases', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], ' alice@signoz.io ');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));

View File

@@ -32,14 +32,14 @@ describe('InviteMembers - Rendering', () => {
render(<InviteMembers />);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<InviteMembers showHeader={false} />);
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
expect(screen.queryByText('Roles')).not.toBeInTheDocument();
});
it('renders add button by default', () => {
@@ -89,7 +89,7 @@ describe('InviteMembers - Rendering', () => {
it('renders role select for each row', () => {
render(<InviteMembers initialRowCount={2} />);
const roleSelects = screen.getAllByText('Select role');
const roleSelects = screen.getAllByText('Select roles');
expect(roleSelects).toHaveLength(2);
});
});

View File

@@ -40,8 +40,8 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -73,17 +73,17 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByTitle('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.type(emailInputs[2], 'charlie@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const adminOptions = await screen.findAllByText('Admin');
await user.click(screen.getAllByText('Select roles')[0]);
const adminOptions = await screen.findAllByTitle('Admin');
await user.click(adminOptions[adminOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
@@ -125,8 +125,8 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -154,8 +154,8 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -218,12 +218,12 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByTitle('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
@@ -276,8 +276,8 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -303,8 +303,8 @@ describe('InviteMembers - Submission', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));

View File

@@ -35,8 +35,8 @@ describe('InviteMembers - Validation', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -60,8 +60,8 @@ describe('InviteMembers - Validation', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
@@ -85,8 +85,8 @@ describe('InviteMembers - Validation', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
@@ -149,8 +149,8 @@ describe('InviteMembers - Validation', () => {
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await waitFor(() => {
expect(
@@ -204,8 +204,8 @@ describe('InviteMembers - Validation', () => {
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByTitle('Viewer'));
await user.click(screen.getByTestId('submit-btn'));

View File

@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
export interface InviteMemberRow {
id: string;
email: string;
roleId: string;
roleIds: string[];
}
export interface InviteResult {
@@ -38,7 +38,7 @@ export interface UseInviteMembersReturn {
addRow: () => void;
removeRow: (id: string) => void;
updateEmail: (id: string, email: string) => void;
updateRole: (id: string, roleId: string | undefined) => void;
updateRole: (id: string, roleIds: string[]) => void;
reset: () => void;
submit: () => Promise<InviteResult[]>;

View File

@@ -18,11 +18,11 @@ import {
const createEmptyRow = (): InviteMemberRow => ({
id: uuid(),
email: '',
roleId: '',
roleIds: [],
});
const isRowTouched = (row: InviteMemberRow): boolean =>
row.email.trim() !== '' || row.roleId !== '';
row.email.trim() !== '' || row.roleIds.length > 0;
export function useInviteMembers(
options: UseInviteMembersOptions = {},
@@ -78,7 +78,7 @@ export function useInviteMembers(
touched.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = row.roleId !== '';
const roleValid = row.roleIds.length > 0;
if (!emailValid || !row.email) {
isValid = false;
@@ -139,12 +139,12 @@ export function useInviteMembers(
);
const updateRole = useCallback(
(id: string, roleId: string | undefined): void => {
(id: string, roleIds: string[]): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.roleId = roleId ?? '';
row.roleIds = roleIds;
}
return updated;
});
@@ -187,7 +187,7 @@ export function useInviteMembers(
await createUser({
email: row.email.trim(),
frontendBaseUrl: getBaseUrl(),
userRoles: [{ id: row.roleId }],
userRoles: row.roleIds.map((id) => ({ id })),
});
results.push({ email: row.email, success: true });
} catch (err) {

View File

@@ -0,0 +1,49 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.tooltipContent {
--tooltip-z-index: 2100;
}
.dropdownContent {
--dropdown-menu-content-z-index: 2100;
}
.leftSection {
display: flex;
align-items: center;
gap: 8px;
}
.divider {
height: 16px;
margin: 0;
}
.timestamp {
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--l1-foreground);
letter-spacing: -0.07px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.arrows {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border-radius: 6px;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,153 @@
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Typography } from '@signozhq/ui/typography';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils';
import { toast } from '@signozhq/ui/sonner';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import {
ChevronDown,
ChevronUp,
Compass,
Copy,
Ellipsis,
Link,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
import { MouseEvent, MouseEventHandler } from 'react';
import { useCopyToClipboard } from 'react-use';
import styles from './LogDetailsHeader.module.scss';
const TOOLTIP_CONTENT_PROPS = { className: styles.tooltipContent };
interface LogDetailsHeaderProps {
log: ILog;
onNavigatePrev: () => void;
onNavigateNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
showOpenInExplorer?: boolean;
onOpenInExplorer?: MouseEventHandler;
}
function LogDetailsHeader({
log,
onNavigatePrev,
onNavigateNext,
isPrevDisabled,
isNextDisabled,
showOpenInExplorer = false,
onOpenInExplorer,
}: LogDetailsHeaderProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { onLogCopy } = useCopyLogLink(log?.id);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const handleCopyLog = (): void => {
copyToClipboard(aggregateAttributesResourcesToString(log));
toast.success('Copied to clipboard', { position: 'bottom-right' });
};
const menuItems = [
{
key: 'copy-log',
label: 'Copy log',
icon: <Copy size={14} />,
onClick: handleCopyLog,
},
{
key: 'copy-link',
label: 'Copy link to log',
icon: <Link size={14} />,
onClick: (): void => onLogCopy(),
},
];
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
<Divider type="vertical" className={styles.divider} />
<Typography.Text
className={styles.timestamp}
data-testid="log-details-header-timestamp"
>
{formatTimezoneAdjustedTimestamp(
log.date ?? log.timestamp,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
</Typography.Text>
</div>
<div className={styles.actions}>
{showOpenInExplorer && (
<Button
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
onClick={onOpenInExplorer}
>
Open in Explorer
</Button>
)}
<Dropdown
menu={{ items: menuItems }}
align="end"
className={styles.dropdownContent}
onClick={(e: MouseEvent): void => e.stopPropagation()}
>
<Button
variant="link"
color="secondary"
prefix={<Ellipsis size={16} />}
data-testid="log-details-header-menu"
/>
</Dropdown>
<div className={styles.arrows}>
<TooltipSimple
title="Move to previous log"
side="top"
open={isPrevDisabled ? false : undefined}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
disabled={isPrevDisabled}
onClick={onNavigatePrev}
data-testid="log-details-header-prev"
/>
</TooltipSimple>
<TooltipSimple
title="Move to next log"
side="top"
open={isNextDisabled ? false : undefined}
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
disabled={isNextDisabled}
onClick={onNavigateNext}
data-testid="log-details-header-next"
/>
</TooltipSimple>
</div>
</div>
</div>
);
}
LogDetailsHeader.defaultProps = {
showOpenInExplorer: false,
onOpenInExplorer: undefined,
};
export default LogDetailsHeader;

View File

@@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react';
import { ILog } from 'types/api/logs/log';
interface UseLogNavigationParams {
logs?: ILog[];
activeLogId: string;
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (id: string) => void;
}
interface UseLogNavigationReturn {
goToPrev: () => void;
goToNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
}
export function useLogNavigation({
logs,
activeLogId,
onNavigateLog,
onScrollToLog,
}: UseLogNavigationParams): UseLogNavigationReturn {
const currentIndex = useMemo(
() => logs?.findIndex((l) => l.id === activeLogId) ?? -1,
[logs, activeLogId],
);
const canNavigate = !!logs?.length && !!onNavigateLog && currentIndex !== -1;
const isPrevDisabled = !canNavigate || currentIndex <= 0;
const isNextDisabled = !canNavigate || currentIndex >= (logs?.length ?? 0) - 1;
const goToPrev = useCallback((): void => {
if (isPrevDisabled || !logs) {
return;
}
const prev = logs[currentIndex - 1];
onNavigateLog?.(prev);
onScrollToLog?.(prev.id);
}, [isPrevDisabled, logs, currentIndex, onNavigateLog, onScrollToLog]);
const goToNext = useCallback((): void => {
if (isNextDisabled || !logs) {
return;
}
const next = logs[currentIndex + 1];
onNavigateLog?.(next);
onScrollToLog?.(next.id);
}, [isNextDisabled, logs, currentIndex, onNavigateLog, onScrollToLog]);
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
}

View File

@@ -0,0 +1,161 @@
import { toast } from '@signozhq/ui/sonner';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { ILog } from 'types/api/logs/log';
import LogDetail from '..';
import { VIEW_TYPES } from '../constants';
import { LogDetailProps } from '../LogDetail.interfaces';
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
}));
const mockLog: ILog = {
id: 'log-1',
timestamp: '2024-01-15T09:45:30Z',
date: '2024-01-15T09:45:30Z',
body: 'test log body',
severityText: 'INFO',
severityNumber: 9,
traceFlags: 0,
traceId: '',
spanID: '',
attributesString: {},
attributesInt: {},
attributesFloat: {},
resources_string: {},
scope_string: {},
attributes_string: {},
severity_text: 'INFO',
severity_number: 9,
};
const makeLog = (id: string): ILog => ({ ...mockLog, id });
function renderDrawer(props: Partial<LogDetailProps> = {}): void {
render(
<LogDetail
log={mockLog}
selectedTab={VIEW_TYPES.OVERVIEW}
onAddToQuery={jest.fn()}
onClickActionItem={jest.fn()}
onClose={jest.fn()}
{...props}
/>,
);
}
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();
});
it('renders the revamped header when a log is provided', () => {
renderDrawer();
expect(screen.getByTestId('log-details-header-menu')).toBeInTheDocument();
expect(screen.getByTestId('log-details-header-prev')).toBeInTheDocument();
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
});
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
// Pin the timezone to UTC so the formatted output is deterministic across
// machines/CI (Jest doesn't fix a TZ).
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
renderDrawer();
// mockLog date is 2024-01-15T09:45:30Z → DASH_DATETIME in UTC.
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
'Jan 15, 2024 ⎯ 09:45:30',
);
});
it('copies the log link from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByTestId('log-details-header-menu'));
await user.click(await screen.findByText('Copy link to log'));
expect(toast.success).toHaveBeenCalled();
});
it('copies the log from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByTestId('log-details-header-menu'));
await user.click(await screen.findByText('Copy log'));
expect(toast.success).toHaveBeenCalledWith('Copied to clipboard', {
position: 'bottom-right',
});
});
it('shows "Open in Explorer" when a handleOpenInExplorer handler is provided', () => {
renderDrawer({ handleOpenInExplorer: jest.fn() });
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
});
it('hides "Open in Explorer" when no handleOpenInExplorer handler is provided', () => {
renderDrawer();
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
});
it('navigates to the next / previous log with the Down / Up arrow keys', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logs = [makeLog('log-0'), makeLog('log-1'), makeLog('log-2')];
const onNavigateLog = jest.fn();
const onScrollToLog = jest.fn();
// Active log is the middle one so both directions are available.
renderDrawer({ log: logs[1], logs, onNavigateLog, onScrollToLog });
await user.keyboard('{ArrowDown}');
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[2]);
expect(onScrollToLog).toHaveBeenLastCalledWith('log-2');
await user.keyboard('{ArrowUp}');
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[0]);
expect(onScrollToLog).toHaveBeenLastCalledWith('log-0');
});
it('does not navigate past the first log on ArrowUp', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logs = [makeLog('log-0'), makeLog('log-1')];
const onNavigateLog = jest.fn();
renderDrawer({ log: logs[0], logs, onNavigateLog });
await user.keyboard('{ArrowUp}');
expect(onNavigateLog).not.toHaveBeenCalled();
});
it('navigates via the header up / down buttons and disables them at boundaries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logs = [makeLog('log-0'), makeLog('log-1')];
const onNavigateLog = jest.fn();
// Active log is the first one.
renderDrawer({ log: logs[0], logs, onNavigateLog });
expect(screen.getByTestId('log-details-header-prev')).toBeDisabled();
expect(screen.getByTestId('log-details-header-next')).toBeEnabled();
await user.click(screen.getByTestId('log-details-header-next'));
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[1]);
});
});

View File

@@ -1,3 +1,10 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -8,7 +8,9 @@ import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import LogStateIndicator, {
LogType,
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -23,6 +25,7 @@ import {
} from 'container/LogDetailedView/utils';
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -48,8 +51,10 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import './LogDetails.styles.scss';
@@ -96,7 +101,8 @@ function LogDetailInner({
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover')
target.closest('.query-status-popover') ||
target.closest('[data-radix-popper-content-wrapper]')
) {
return;
}
@@ -112,49 +118,30 @@ function LogDetailInner({
};
}, [onClose]);
// Keyboard navigation - handle up/down arrow keys
// Only listen when in OVERVIEW tab
// eslint-disable-next-line sonarjs/cognitive-complexity
const { goToPrev, goToNext, isPrevDisabled, isNextDisabled } =
useLogNavigation({
logs,
activeLogId: log.id,
onNavigateLog,
onScrollToLog,
});
// Keyboard navigation - handle up/down arrow keys. Only listen in the OVERVIEW
// tab so we don't hijack arrow keys from the JSON editor / context view.
useEffect(() => {
if (
!logs ||
!onNavigateLog ||
logs.length === 0 ||
selectedView !== VIEW_TYPES.OVERVIEW
) {
return;
if (selectedView !== VIEW_TYPES.OVERVIEW) {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const currentIndex = logs.findIndex((l) => l.id === log.id);
if (currentIndex === -1) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
// Navigate to previous log
if (currentIndex > 0) {
const prevLog = logs[currentIndex - 1];
onNavigateLog(prevLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(prevLog.id);
}
}
goToPrev();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
// Navigate to next log
if (currentIndex < logs.length - 1) {
const nextLog = logs[currentIndex + 1];
onNavigateLog(nextLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(nextLog.id);
}
}
goToNext();
}
};
@@ -162,7 +149,7 @@ function LogDetailInner({
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [log.id, logs, onNavigateLog, onScrollToLog, selectedView]);
}, [selectedView, goToPrev, goToNext]);
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) {
@@ -303,33 +290,6 @@ function LogDetailInner({
};
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
!logs || !onNavigateLog || logs.length === 0 || currentLogIndex <= 0;
const isNextDisabled =
!logs ||
!onNavigateLog ||
logs.length === 0 ||
currentLogIndex === logs.length - 1;
type HandleNavigateLogParams = {
direction: 'next' | 'previous';
};
const handleNavigateLog = ({ direction }: HandleNavigateLogParams): void => {
if (!logs || !onNavigateLog || currentLogIndex === -1) {
return;
}
if (direction === 'previous' && !isPrevDisabled) {
const prevLog = logs[currentLogIndex - 1];
onNavigateLog(prevLog);
onScrollToLog?.(prevLog.id);
} else if (direction === 'next' && !isNextDisabled) {
const nextLog = logs[currentLogIndex + 1];
onNavigateLog(nextLog);
onScrollToLog?.(nextLog.id);
}
};
return (
<Drawer
@@ -338,57 +298,69 @@ function LogDetailInner({
maskClosable={false}
getContainer={getContainer}
title={
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={(): void => handleNavigateLog({ direction: 'next' })}
/>
</Tooltip>
isLogDetailsV2 ? (
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
) : (
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
{handleOpenInExplorer && (
<div>
<Button
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
className="open-in-explorer-btn"
onClick={handleOpenInExplorer}
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
Open in Explorer
</Button>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={goToPrev}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={goToNext}
/>
</Tooltip>
</div>
)}
{handleOpenInExplorer && (
<div>
<Button
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
className="open-in-explorer-btn"
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
</div>
</div>
)
}
placement="right"
onClose={drawerCloseHandler}
@@ -407,7 +379,15 @@ function LogDetailInner({
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
{isLogDetailsV2 ? (
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
) : (
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
)}
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
@@ -483,22 +463,25 @@ function LogDetailInner({
</Tooltip>
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Copy size={12} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
{/* V2 moves copy actions into the header ⋯ menu */}
{!isLogDetailsV2 && (
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Copy size={12} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -92,6 +92,7 @@ const ROUTES = {
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: '/ai-observability/attribute-mapping',
AI_OBSERVABILITY_BASE: '/ai-observability',
AI_OBSERVABILITY_OVERVIEW: '/ai-observability/overview',
AI_OBSERVABILITY_EXPLORER: '/ai-observability/explorer',
AI_OBSERVABILITY_CONFIGURATION: '/ai-observability/configuration',
} as const;

View File

@@ -17,9 +17,12 @@ import { ChevronDown, Dot, PencilLine, Plug, Plus } from '@signozhq/icons';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import GcpCloudAccountSetupDrawer from '../../GoogleCloudPlatform/AddNewAccount/CloudAccountSetupDrawer';
import GcpAccountSettingsDrawer from '../../GoogleCloudPlatform/EditAccount/AccountSettingsDrawer';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
mapAccountDtoToGcpCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
@@ -156,6 +159,18 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
});
}
if (type === IntegrationType.GCP_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToGcpCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
@@ -207,13 +222,23 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
const { config } = activeAccount;
let enabledRegions: string[];
if ('regions' in config) {
// AWS
enabledRegions = config.regions;
} else if ('resource_groups' in config) {
// Azure
enabledRegions = config.resource_groups;
} else {
// GCP
enabledRegions = config.project_ids;
}
logEvent(`${type} Integration: Account viewed`, {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
enabledRegions,
});
}
}, [activeAccount, type]);
@@ -260,6 +285,11 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpCloudAccountSetupDrawer
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
)}
@@ -281,6 +311,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
setActiveAccount={setActiveAccount}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpAccountSettingsDrawer
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
)}
</div>

View File

@@ -46,14 +46,34 @@ const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
s3BucketsByRegion: {},
};
const GCP_METRICS_INFO_TOOLTIP =
'These are suggested metrics for your OpenTelemetry Collector Configuration. The metrics you actually receive may vary based on the metrics listed in your collector config.';
function getIntegrationServiceConfig(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
):
| { logs?: { enabled?: boolean }; metrics?: { enabled?: boolean } }
| undefined {
const config = serviceDetailsData?.cloudIntegrationService?.config;
if (type === IntegrationType.AWS_SERVICES) {
return config?.aws;
}
if (type === IntegrationType.GCP_SERVICES) {
return config?.gcp;
}
return config?.azure;
}
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
@@ -98,16 +118,21 @@ function getServiceConfigPayload({
};
}
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
// Azure and GCP share the same simple logs/metrics enable-flag shape.
const signalConfig = {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
};
if (type === IntegrationType.GCP_SERVICES) {
return { gcp: signalConfig };
}
return { azure: signalConfig };
}
function ServiceDetails({
@@ -162,10 +187,10 @@ function ServiceDetails({
? isAccountServiceLoading
: isReadOnlyServiceLoading;
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);
@@ -477,6 +502,11 @@ function ServiceDetails({
<CloudServiceDataCollected
logsData={serviceDetailsData?.dataCollected?.logs || []}
metricsData={serviceDetailsData?.dataCollected?.metrics || []}
metricsInfoTooltip={
type === IntegrationType.GCP_SERVICES
? GCP_METRICS_INFO_TOOLTIP
: undefined
}
/>
</div>
);

View File

@@ -36,8 +36,12 @@ function AccountSettingsModal({
const queryClient = useQueryClient();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const azureConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);

View File

@@ -0,0 +1,142 @@
.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

@@ -0,0 +1,288 @@
import { useEffect, 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 { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
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: '',
};
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
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) {
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
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading}
disabled={isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
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>
);
}
export default CloudAccountSetupDrawer;

View File

@@ -0,0 +1,71 @@
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.headLabel {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.mono {
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.secretsBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.skeletonLabel :global(.ant-skeleton-input) {
width: 120px;
min-width: 120px;
height: 14px;
}
.skeletonInput :global(.ant-skeleton-input) {
width: 100%;
min-width: 100%;
height: 36px;
}
.readonlyField {
display: flex;
gap: var(--spacing-2);
align-items: center;
height: 36px;
padding: 0 var(--spacing-2) 0 var(--spacing-4);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
.readonlyValue {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
// Match the other fields' value text: 13px and the brighter --l1-foreground.
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
cursor: not-allowed;
}

View File

@@ -0,0 +1,193 @@
import { Lock } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
type CredentialField = keyof CloudintegrationtypesCredentialsDTO;
interface FieldConfig {
name: CredentialField;
label: string;
tooltip: string;
placeholder: string;
testId: string;
type: SecretFieldType;
}
const FIELDS: FieldConfig[] = [
{
name: 'sigNozApiUrl',
label: 'SigNoz API URL',
tooltip: 'Base URL of your SigNoz instance the collector reports to',
placeholder: 'https://<tenant>.signoz.cloud',
testId: 'gcp-signoz-api-url-input',
type: 'url',
},
{
name: 'sigNozApiKey',
label: 'SigNoz API Key',
tooltip: 'API key used to authenticate with your SigNoz instance',
placeholder: 'Enter SigNoz API key',
testId: 'gcp-signoz-api-key-input',
type: 'text',
},
{
name: 'ingestionUrl',
label: 'Ingestion URL',
tooltip: 'OTLP ingestion endpoint your OTel Collector sends telemetry to',
placeholder: 'https://ingest.<region>.signoz.cloud',
testId: 'gcp-ingestion-url-input',
type: 'url',
},
{
name: 'ingestionKey',
label: 'Ingestion Key',
tooltip: 'Ingestion key that authorizes telemetry sent to SigNoz',
placeholder: 'Enter ingestion key',
testId: 'gcp-ingestion-key-input',
type: 'text',
},
];
interface ConnectionSecretsFieldsProps {
control: Control<GcpSetupFormValues>;
isLoading: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO;
}
function ConnectionSecretsFields({
control,
isLoading,
connectionParams,
}: ConnectionSecretsFieldsProps): JSX.Element {
const hasMissingValue = FIELDS.some(
(field) => !connectionParams?.[field.name],
);
return (
<div className={styles.drawerSurface}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Deployment details &amp; ingestion secrets
</Typography.Text>
{!hasMissingValue && (
<div className={styles.headLabel}>
<Lock size={12} />
<Typography.Text as="span" size="small" className={styles.headLabel}>
Auto-filled by SigNoz
</Typography.Text>
</div>
)}
</div>
{isLoading ? (
<div className={styles.secretsBody} data-testid="gcp-secrets-skeleton">
{FIELDS.map((field) => (
<div key={field.name} className={styles.drawerSection}>
<Skeleton.Input active size="small" className={styles.skeletonLabel} />
<Skeleton.Input active block className={styles.skeletonInput} />
</div>
))}
</div>
) : (
<div className={styles.secretsBody}>
{FIELDS.map((field) => {
// Backend-provided values are read-only — the user can't edit them, so
// show a truncated value with a copy button. Missing values (enterprise)
// stay editable inputs with no copy button.
const providedValue = connectionParams?.[field.name];
if (providedValue) {
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<div className={styles.readonlyField}>
<Typography.Text
as="span"
id={field.testId}
className={cx(styles.readonlyValue, styles.mono)}
title={providedValue}
testId={field.testId}
>
{providedValue}
</Typography.Text>
<CopyButton
value={providedValue}
size={12}
ariaLabel={`Copy ${field.label}`}
testId={`${field.testId}-copy`}
onCopy={(): void => {
toast.success(`${field.label} copied to clipboard`, {
position: 'bottom-right',
});
}}
/>
</div>
</div>
);
}
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>
);
})}
</div>
)}
</div>
);
}
ConnectionSecretsFields.defaultProps = {
connectionParams: undefined,
};
export default ConnectionSecretsFields;

View File

@@ -0,0 +1,22 @@
.fieldLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.required {
composes: required from './shared.module.scss';
}
.infoTrigger {
display: inline-flex;
align-items: center;
color: var(--l3-foreground);
cursor: help;
}
.tooltipContent {
max-width: 240px;
white-space: normal;
word-break: break-word;
}

View File

@@ -0,0 +1,49 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './FieldLabel.module.scss';
interface FieldLabelProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
}
function FieldLabel({
htmlFor,
label,
tooltip,
required,
}: FieldLabelProps): JSX.Element {
return (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
<TooltipSimple
title={tooltip}
side="top"
tooltipContentProps={{ className: styles.tooltipContent }}
>
<span
className={styles.infoTrigger}
aria-label={`${label} help`}
data-testid={`${htmlFor}-tooltip`}
>
<Info size={12} />
</span>
</TooltipSimple>
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</label>
);
}
FieldLabel.defaultProps = {
required: false,
};
export default FieldLabel;

View File

@@ -0,0 +1,84 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.flowRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
width: 100%;
padding: var(--spacing-5) var(--spacing-6);
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-2);
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&.flowRadioManual:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
&:has(button[disabled]) {
cursor: not-allowed;
opacity: 0.55;
&:hover {
background: var(--l3-background);
}
}
}
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,76 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup, RadioGroupItem } 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
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
className={styles.flowRadioGroup}
>
<RadioGroupItem
value="manual"
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
testId="gcp-flow-manual"
>
<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>
</RadioGroupItem>
<RadioGroupItem
value="agent"
containerClassName={styles.flowRadio}
testId="gcp-flow-agent"
disabled
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="robin" variant="default">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</RadioGroupItem>
</RadioGroup>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,13 @@
.guideLink {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--callout-primary-title);
font-size: var(--periscope-font-size-base);
text-decoration: none;
&:hover {
color: var(--accent-primary);
text-decoration: underline;
}
}

View File

@@ -0,0 +1,31 @@
import { ArrowUpRight, KeyRound } from '@signozhq/icons';
import { Callout } from '@signozhq/ui/callout';
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/';
function SetupGuideCallout(): 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.
</Typography.Text>
<a
className={styles.guideLink}
href={GCP_INTEGRATION_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="gcp-setup-guide-link"
>
GCP integration guide
<ArrowUpRight size={12} />
</a>
</Callout>
);
}
export default SetupGuideCallout;

View File

@@ -0,0 +1,36 @@
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection > label {
font-size: var(--periscope-font-size-normal);
color: var(--l2-foreground);
}
.required {
color: var(--accent-cherry);
}
.fieldError {
font-size: var(--periscope-font-size-small);
color: var(--accent-cherry);
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.mono {
font-family: var(--font-family-mono);
}

View File

@@ -0,0 +1,12 @@
export type SetupFlow = 'manual' | 'agent';
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;
deploymentRegion: string;
projectIds: string[];
sigNozApiUrl: string;
sigNozApiKey: string;
ingestionUrl: string;
ingestionKey: string;
}

View File

@@ -0,0 +1,24 @@
export type SecretFieldType = 'url' | 'text';
export function isValidUrl(value: string): boolean {
try {
return Boolean(new URL(value));
} catch {
return false;
}
}
export function validateSecretValue(
label: string,
type: SecretFieldType,
value: string | undefined,
): true | string {
const trimmed = value?.trim();
if (!trimmed) {
return `Please enter the ${label}`;
}
if (type === 'url' && !isValidUrl(trimmed)) {
return `Please enter a valid URL for ${label}`;
}
return true;
}

View File

@@ -0,0 +1,64 @@
.body {
display: flex;
flex-direction: column;
gap: 17px;
border-radius: 3px;
}
.connectedAccountDetails {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.connectedAccountDetailsTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.accountId {
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
.accountIdValue {
font-family: 'Geist Mono';
font-size: 12px;
font-weight: var(--font-weight-bold);
line-height: 18px;
letter-spacing: -0.06px;
}
.regionSelector {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.regionSelectorTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.regionSelectorDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
.footer {
display: flex;
flex-direction: row;
gap: var(--spacing-5);
justify-content: space-between;
width: 100%;
}

View File

@@ -0,0 +1,156 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
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 { invalidateListAccounts } from 'api/generated/services/cloudintegration';
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 styles from './AccountSettingsDrawer.module.scss';
interface AccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: AccountSettingsDrawerProps): JSX.Element {
const {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
} = useAccountSettingsDrawer({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const gcpConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className={styles.footer}>
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
data-testid="gcp-update-account-btn"
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
projectIds: gcpConfig?.project_ids || [],
}}
>
<div className={styles.body}>
<div className={styles.connectedAccountDetails}>
<div className={styles.connectedAccountDetailsTitle}>
Connected Account details
</div>
<div className={styles.accountId}>
Account Name:{' '}
<span className={styles.accountIdValue}>
{account?.providerAccountId}
</span>
</div>
</div>
{gcpConfig?.deployment_project_id && (
<div className={styles.regionSelector}>
<div className={styles.regionSelectorTitle}>Deployment project ID</div>
<div className={styles.regionSelectorDescription}>
{gcpConfig.deployment_project_id}
</div>
</div>
)}
{gcpConfig?.deployment_region && (
<div className={styles.regionSelector}>
<div className={styles.regionSelectorTitle}>Deployment region</div>
<div className={styles.regionSelectorDescription}>
{gcpConfig.deployment_region}
</div>
</div>
)}
<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>
<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>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsDrawer;

View File

@@ -0,0 +1,230 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNT_URL,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAccountConfig,
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(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const onClose = jest.fn();
const setActiveAccount = jest.fn();
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<TooltipProvider>
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>
</TooltipProvider>
</MockQueryClientProvider>,
);
};
/** 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[] =>
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
'close',
);
describe('GCP AccountSettingsDrawer', () => {
let updatePayload: Record<string, unknown> | null;
beforeEach(() => {
updatePayload = null;
server.use(
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listAccountsResponse)),
),
rest.put(GCP_ACCOUNT_URL, async (req: RestRequest, res, ctx) => {
updatePayload = await req.json();
return res(ctx.status(204));
}),
);
});
it('renders the connected account details and existing project IDs', () => {
renderDrawer();
expect(screen.getByText(gcpAccount.providerAccountId)).toBeInTheDocument();
expect(
screen.getByText(gcpAccountConfig.deployment_project_id),
).toBeInTheDocument();
expect(
screen.getByText(gcpAccountConfig.deployment_region),
).toBeInTheDocument();
gcpAccountConfig.project_ids.forEach((projectId) => {
expect(screen.getByTitle(projectId)).toBeInTheDocument();
});
});
it('keeps save disabled until the project IDs actually change', async () => {
const user = userEvent.setup();
renderDrawer();
expect(screen.getByTestId('gcp-update-account-btn')).toBeDisabled();
await user.type(getProjectIdsInput(), 'project-c,');
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).toBeEnabled();
});
});
it('sends the updated project IDs while preserving the immutable deployment fields', async () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-update-account-btn'));
await waitFor(() => {
expect(updatePayload).not.toBeNull();
});
expect(updatePayload).toStrictEqual({
config: {
gcp: {
deploymentRegion: gcpAccountConfig.deployment_region,
deploymentProjectId: gcpAccountConfig.deployment_project_id,
projectIds: ['project-a', 'project-b', 'project-c'],
},
},
});
await waitFor(() => {
expect(setActiveAccount).toHaveBeenCalledWith({
...gcpAccount,
config: {
deployment_region: gcpAccountConfig.deployment_region,
deployment_project_id: gcpAccountConfig.deployment_project_id,
project_ids: ['project-a', 'project-b', 'project-c'],
},
});
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith(
'Account settings updated successfully',
expect.anything(),
);
});
it('blocks the update and shows a validation error when every project ID is removed', async () => {
const user = userEvent.setup();
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
while (getProjectIdTagRemoveButtons().length > 0) {
// eslint-disable-next-line no-await-in-loop
await user.click(getProjectIdTagRemoveButtons()[0]);
}
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-update-account-btn'));
await waitFor(() => {
expect(
screen.getByText('Please add at least one project ID'),
).toBeInTheDocument();
});
expect(updatePayload).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it('surfaces a toast and keeps the drawer open when the update fails', async () => {
server.use(
rest.put(GCP_ACCOUNT_URL, (_req, res, ctx) =>
res(
ctx.status(500),
ctx.json({ status: 'error', error: { message: 'update failed' } }),
),
),
);
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-update-account-btn'));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'Failed to update account settings',
expect.anything(),
);
});
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,210 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
checkInResponse,
CLOUD_INTEGRATION_ID,
connectionCredentials,
connectionCredentialsResponse,
createAccountResponse,
GCP_ACCOUNTS_URL,
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
} from './mockData';
// `useCloudAccountSetupDrawer` 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(),
}));
const onClose = jest.fn();
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<TooltipProvider>
<CloudAccountSetupDrawer onClose={onClose} />
</TooltipProvider>
</MockQueryClientProvider>,
);
};
describe('GCP CloudAccountSetupDrawer', () => {
let createAccountPayload: Record<string, unknown> | null;
let checkInPayload: Record<string, unknown> | null;
beforeEach(() => {
createAccountPayload = null;
checkInPayload = null;
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, async (req: RestRequest, res, ctx) => {
checkInPayload = await req.json();
return res(ctx.status(200), ctx.json(checkInResponse));
}),
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
createAccountPayload = await req.json();
return res(ctx.status(201), ctx.json(createAccountResponse));
}),
);
});
it('renders SigNoz-provided credentials as read-only fields', async () => {
renderDrawer();
await waitFor(() => {
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
connectionCredentials.sigNozApiUrl,
);
});
expect(screen.getByTestId('gcp-signoz-api-key-input')).toHaveTextContent(
connectionCredentials.sigNozApiKey,
);
expect(screen.getByTestId('gcp-ingestion-url-input')).toHaveTextContent(
connectionCredentials.ingestionUrl,
);
expect(screen.getByTestId('gcp-ingestion-key-input')).toHaveTextContent(
connectionCredentials.ingestionKey,
);
expect(screen.getByText('Auto-filled by SigNoz')).toBeInTheDocument();
});
it('blocks submission and surfaces validation errors when the form is empty', async () => {
const user = userEvent.setup();
renderDrawer();
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-account-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-connect-account-btn'));
await waitFor(() => {
expect(screen.getByText('Please enter an account name')).toBeInTheDocument();
});
expect(
screen.getByText('Please enter the deployment project ID'),
).toBeInTheDocument();
expect(screen.getByText('Please select a region')).toBeInTheDocument();
expect(
screen.getByText('Please add at least one project ID'),
).toBeInTheDocument();
expect(createAccountPayload).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it('creates the account, checks the agent in, and closes the drawer', async () => {
const user = userEvent.setup();
renderDrawer();
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-account-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-account-name-input'),
'billing@company.com',
);
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)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,project-b,');
await user.click(screen.getByTestId('gcp-connect-account-btn'));
await waitFor(() => {
expect(createAccountPayload).not.toBeNull();
});
expect(createAccountPayload).toStrictEqual({
config: {
gcp: {
deploymentRegion: 'asia-south1',
deploymentProjectId: 'my-deployment-project-123',
projectIds: ['project-a', 'project-b'],
},
},
// Backend-provided credentials win over anything in the form.
credentials: connectionCredentials,
});
await waitFor(() => {
expect(checkInPayload).toStrictEqual({
providerAccountId: 'billing@company.com',
cloudIntegrationId: CLOUD_INTEGRATION_ID,
data: {},
});
});
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('shows the backend error inline when account creation fails', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: { message: 'deployment project id is not accessible' },
}),
),
),
);
const user = userEvent.setup();
renderDrawer();
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-account-btn')).toBeEnabled();
});
await user.type(screen.getByTestId('gcp-account-name-input'), 'my-org');
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)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,');
await user.click(screen.getByTestId('gcp-connect-account-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
'deployment project id is not accessible',
);
});
expect(checkInPayload).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,65 @@
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudAccount,
GCPCloudAccountConfig,
} from 'container/Integrations/types';
export const GCP_CREDENTIALS_URL =
'http://localhost/api/v1/cloud_integrations/gcp/credentials';
export const GCP_ACCOUNTS_URL =
'http://localhost/api/v1/cloud_integrations/gcp/accounts';
export const GCP_CHECK_IN_URL =
'http://localhost/api/v1/cloud_integrations/gcp/accounts/check_in';
export const CLOUD_INTEGRATION_ID = 'ci-gcp-1234';
export const GCP_ACCOUNT_ID = 'acc-gcp-1';
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
export const gcpAccountConfig: GCPCloudAccountConfig = {
deployment_region: 'asia-south1',
deployment_project_id: 'my-deployment-project-123',
project_ids: ['project-a', 'project-b'],
};
export const gcpAccount: CloudAccount = {
id: GCP_ACCOUNT_ID,
cloud_account_id: 'gcp-cloud-1',
providerAccountId: 'billing@company.com',
config: gcpAccountConfig,
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const listAccountsResponse = {
status: 'success',
data: { accounts: [] },
};
/**
* Credentials the backend hands out on SigNoz Cloud. When present the drawer
* renders them read-only and sends them back verbatim on submit.
*/
export const connectionCredentials: CloudintegrationtypesCredentialsDTO = {
sigNozApiUrl: 'https://tenant.signoz.cloud',
sigNozApiKey: 'signoz-api-key-abc',
ingestionUrl: 'https://ingest.us.signoz.cloud',
ingestionKey: 'ingestion-key-xyz',
};
export const connectionCredentialsResponse = {
status: 'success',
data: connectionCredentials,
};
export const createAccountResponse = {
status: 'success',
data: {
id: CLOUD_INTEGRATION_ID,
connectionArtifact: {},
},
};
export const checkInResponse = {
status: 'success',
data: {},
};

View File

@@ -60,6 +60,44 @@ function RemoveIntegrationAccount({
setIsModalOpen(false);
};
let modalDescription: JSX.Element;
if (cloudProvider === INTEGRATION_TYPES.AWS) {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it. <br />
<br />
Since you manage the GCP resources yourself, remember to manually tear down
the OTel collector and Pub/Sub resources you created for this integration if
you no longer need them.
</>
);
} else {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
);
}
return (
<div className="remove-integration-account-container">
<Button
@@ -84,28 +122,7 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
{modalDescription}
</Modal>
</div>
);

View File

@@ -47,3 +47,27 @@ export function mapAccountDtoToAzureCloudAccount(
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
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 ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -13,8 +13,8 @@ import { ArrowLeft, MoveUpRight, RotateCw } from '@signozhq/icons';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import CloudIntegration from '../CloudIntegration/CloudIntegration';
import { INTEGRATION_TYPES } from '../constants';
import { IntegrationType } from '../types';
import { INTEGRATION_TYPES } from '../constants';
import { handleContactSupport } from '../utils';
import IntegrationDetailContent from './IntegrationDetailContent';
import IntegrationDetailHeader from './IntegrationDetailHeader';
@@ -24,6 +24,12 @@ import { getConnectionStatesFromConnectionStatus } from './utils';
import './IntegrationDetailPage.styles.scss';
const cloudIntegrationTypeById: Record<string, IntegrationType> = {
[INTEGRATION_TYPES.AWS]: IntegrationType.AWS_SERVICES,
[INTEGRATION_TYPES.AZURE]: IntegrationType.AZURE_SERVICES,
[INTEGRATION_TYPES.GCP]: IntegrationType.GCP_SERVICES,
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function IntegrationDetailPage(): JSX.Element {
const history = useHistory();
@@ -55,19 +61,8 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
if (integrationId && cloudIntegrationTypeById[integrationId]) {
return <CloudIntegration type={cloudIntegrationTypeById[integrationId]} />;
}
return (

View File

@@ -1,7 +1,8 @@
import awsDarkLogo from '@/assets/Logos/aws-dark.svg';
import azureOpenaiLogo from '@/assets/Logos/azure-openai.svg';
import gcpLogo from '@/assets/Logos/gcp.svg';
import { AzureRegion } from './types';
import { AzureRegion, GCPRegion } from './types';
export const INTEGRATION_TELEMETRY_EVENTS = {
INTEGRATIONS_LIST_VISITED: 'Integrations Page: Visited the list page',
@@ -21,6 +22,7 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
export const INTEGRATION_TYPES = {
AWS: 'aws',
AZURE: 'azure',
GCP: 'gcp',
};
export const AWS_INTEGRATION = {
@@ -53,7 +55,26 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const GCP_INTEGRATION = {
id: INTEGRATION_TYPES.GCP,
title: 'Google Cloud Platform',
description: 'Setup for GCP monitoring with SigNoz',
author: {
name: 'SigNoz',
email: 'integrations@signoz.io',
homepage: 'https://signoz.io',
},
icon: gcpLogo,
icon_alt: 'gcp-logo',
is_installed: false,
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [
AWS_INTEGRATION,
AZURE_INTEGRATION,
GCP_INTEGRATION,
];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -165,3 +186,66 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'West US 2', value: 'westus2', geography: 'United States' },
{ label: 'West US 3', value: 'westus3', geography: 'United States' },
];
// Source of truth: pkg/types/cloudintegrationtypes/regions.go (GCP regions).
export const GCP_REGIONS: GCPRegion[] = [
{ label: 'Johannesburg', value: 'africa-south1', geography: 'Africa' },
{ label: 'Changhua County', value: 'asia-east1', geography: 'APAC' },
{ label: 'Hong Kong', value: 'asia-east2', geography: 'APAC' },
{ label: 'Tokyo', value: 'asia-northeast1', geography: 'APAC' },
{ label: 'Osaka', value: 'asia-northeast2', geography: 'APAC' },
{ label: 'Seoul', value: 'asia-northeast3', geography: 'APAC' },
{ label: 'Mumbai', value: 'asia-south1', geography: 'APAC' },
{ label: 'Delhi', value: 'asia-south2', geography: 'APAC' },
{ label: 'Singapore', value: 'asia-southeast1', geography: 'APAC' },
{ label: 'Jakarta', value: 'asia-southeast2', geography: 'APAC' },
{ label: 'Bangkok', value: 'asia-southeast3', geography: 'APAC' },
{ label: 'Sydney', value: 'australia-southeast1', geography: 'APAC' },
{ label: 'Melbourne', value: 'australia-southeast2', geography: 'APAC' },
{ label: 'Warsaw', value: 'europe-central2', geography: 'Europe' },
{ label: 'Hamina', value: 'europe-north1', geography: 'Europe' },
{ label: 'Stockholm', value: 'europe-north2', geography: 'Europe' },
{ label: 'Madrid', value: 'europe-southwest1', geography: 'Europe' },
{ label: 'St. Ghislain', value: 'europe-west1', geography: 'Europe' },
{ label: 'London', value: 'europe-west2', geography: 'Europe' },
{ label: 'Frankfurt', value: 'europe-west3', geography: 'Europe' },
{ label: 'Eemshaven', value: 'europe-west4', geography: 'Europe' },
{ label: 'Zurich', value: 'europe-west6', geography: 'Europe' },
{ label: 'Milan', value: 'europe-west8', geography: 'Europe' },
{ label: 'Paris', value: 'europe-west9', geography: 'Europe' },
{ label: 'Berlin', value: 'europe-west10', geography: 'Europe' },
{ label: 'Turin', value: 'europe-west12', geography: 'Europe' },
{ label: 'Doha', value: 'me-central1', geography: 'Middle East' },
{ label: 'Dammam', value: 'me-central2', geography: 'Middle East' },
{ label: 'Tel Aviv', value: 'me-west1', geography: 'Middle East' },
{
label: 'Montréal',
value: 'northamerica-northeast1',
geography: 'North America',
},
{
label: 'Toronto',
value: 'northamerica-northeast2',
geography: 'North America',
},
{
label: 'Querétaro',
value: 'northamerica-south1',
geography: 'North America',
},
{
label: 'São Paulo',
value: 'southamerica-east1',
geography: 'South America',
},
{ label: 'Santiago', value: 'southamerica-west1', geography: 'South America' },
{ label: 'Council Bluffs', value: 'us-central1', geography: 'North America' },
{ label: 'Moncks Corner', value: 'us-east1', geography: 'North America' },
{ label: 'Ashburn', value: 'us-east4', geography: 'North America' },
{ label: 'Columbus', value: 'us-east5', geography: 'North America' },
{ label: 'Dallas', value: 'us-south1', geography: 'North America' },
{ label: 'The Dalles', value: 'us-west1', geography: 'North America' },
{ label: 'Los Angeles', value: 'us-west2', geography: 'North America' },
{ label: 'Salt Lake City', value: 'us-west3', geography: 'North America' },
{ label: 'Las Vegas', value: 'us-west4', geography: 'North America' },
];

View File

@@ -6,6 +6,7 @@ import {
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
GCP_SERVICES = 'gcp',
}
interface LogField {
@@ -87,7 +88,10 @@ export interface ServiceData {
export interface CloudAccount {
id: string;
cloud_account_id: string;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
config:
| AzureCloudAccountConfig
| AWSCloudAccountConfig
| GCPCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
@@ -97,6 +101,12 @@ export interface AzureCloudAccountConfig {
resource_groups: string[];
}
export interface GCPCloudAccountConfig {
deployment_region: string;
deployment_project_id: string;
project_ids: string[];
}
export interface AccountStatus {
integration: IntegrationStatus;
}
@@ -111,6 +121,12 @@ export interface AzureRegion {
value: string;
}
export interface GCPRegion {
label: string;
geography: string;
value: string;
}
export interface UpdateServiceConfigPayload {
cloud_account_id: string;
config: AzureServicesConfig;

View File

@@ -0,0 +1,11 @@
.explorer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-0);
}
.placeholder {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,14 @@
import styles from './Explorer.module.scss';
// Shell for the AI Observability Explorer tab. Owns the
// /ai-observability/explorer route and is intentionally empty for now: the
// query builder + results surface land in a follow-up.
function Explorer(): JSX.Element {
return (
<div className={styles.explorer} data-testid="llm-observability-explorer">
<div className={styles.placeholder}>Explorer coming soon.</div>
</div>
);
}
export default Explorer;

View File

@@ -44,6 +44,7 @@ describe('LLMObservability (integration)', () => {
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
expect(screen.getByTestId('llm-overview-dashboard')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Explorer' })).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Model pricing' }),
).toBeInTheDocument();
@@ -78,6 +79,27 @@ describe('LLMObservability (integration)', () => {
);
});
it('navigates to the explorer route when the Explorer tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Explorer' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.AI_OBSERVABILITY_EXPLORER,
);
});
it('renders the explorer panel on the explorer route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_EXPLORER,
});
expect(screen.getByTestId('llm-observability-explorer')).toBeInTheDocument();
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,

View File

@@ -5,10 +5,12 @@ import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
import Explorer from '../Explorer/Explorer';
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.AI_OBSERVABILITY_OVERVIEW;
const EXPLORER_KEY = ROUTES.AI_OBSERVABILITY_EXPLORER;
const CONFIGURATION_KEY = ROUTES.AI_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING;
@@ -31,6 +33,8 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
activeTab = CONFIGURATION_KEY;
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
activeTab = ATTRIBUTE_MAPPING_KEY;
} else if (pathname.startsWith(EXPLORER_KEY)) {
activeTab = EXPLORER_KEY;
}
const onTabChange = useCallback(
@@ -46,6 +50,11 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
label: 'Overview',
children: <Overview />,
},
{
key: EXPLORER_KEY,
label: 'Explorer',
children: <Explorer />,
},
{
key: CONFIGURATION_KEY,
label: 'Model pricing',

View File

@@ -14,7 +14,7 @@ import './InviteTeamMembers.styles.scss';
interface TeamMember {
email: string;
role: string;
roles: string[];
name: string;
frontendBaseUrl: string;
id: string;
@@ -45,7 +45,7 @@ function InviteTeamMembers({
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
rows.map((row) => ({
email: row.email,
role: roleIdToName[row.roleId] ?? row.roleId,
roles: row.roleIds.map((roleId) => roleIdToName[roleId] ?? roleId),
name: '',
frontendBaseUrl: getBaseUrl(),
id: row.id,

View File

@@ -166,8 +166,8 @@ describe('InviteTeamMembers', () => {
{ email: 'user2@test.com', success: true },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
{ id: 'row-1', email: 'user1@test.com', roleIds: ['role-viewer-id'] },
{ id: 'row-2', email: 'user2@test.com', roleIds: ['role-editor-id'] },
];
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
@@ -177,14 +177,14 @@ describe('InviteTeamMembers', () => {
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
roles: ['VIEWER'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'EDITOR',
roles: ['EDITOR'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
@@ -211,8 +211,8 @@ describe('InviteTeamMembers', () => {
{ email: 'user2@test.com', success: false, error: 'Already exists' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
{ id: 'row-1', email: 'user1@test.com', roleIds: ['role-viewer-id'] },
{ id: 'row-2', email: 'user2@test.com', roleIds: ['role-admin-id'] },
];
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
@@ -222,14 +222,14 @@ describe('InviteTeamMembers', () => {
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
roles: ['VIEWER'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'ADMIN',
roles: ['ADMIN'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
@@ -252,8 +252,8 @@ describe('InviteTeamMembers', () => {
{ email: 'user2@test.com', success: false, error: 'Error 2' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
{ id: 'row-1', email: 'user1@test.com', roleIds: ['role-editor-id'] },
{ id: 'row-2', email: 'user2@test.com', roleIds: ['role-viewer-id'] },
];
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
@@ -263,14 +263,14 @@ describe('InviteTeamMembers', () => {
teamMembers: [
{
email: 'user1@test.com',
role: 'EDITOR',
roles: ['EDITOR'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'VIEWER',
roles: ['VIEWER'],
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',

View File

@@ -206,6 +206,7 @@ export const routesToSkip = [
ROUTES.AI_OBSERVABILITY_OVERVIEW,
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
ROUTES.AI_OBSERVABILITY_EXPLORER,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -10,7 +10,8 @@ import {
export function isOneClickIntegration(integrationId: string): boolean {
return (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
integrationId === INTEGRATION_TYPES.AZURE ||
integrationId === INTEGRATION_TYPES.GCP
);
}

View File

@@ -39,8 +39,12 @@ export function useAccountSettingsModal({
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const accountConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(

View File

@@ -0,0 +1,148 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsDrawer {
form: FormInstance;
isLoading: boolean;
projectIds: string[];
isSaveDisabled: boolean;
setProjectIds: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsDrawerProps): UseAccountSettingsDrawer {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
const [projectIds, setProjectIds] = useState<string[]>(
accountConfig?.project_ids || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
projectIds: accountConfig.project_ids,
});
setProjectIds(accountConfig.project_ids);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
if (!accountConfig) {
return;
}
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.GCP,
id: account?.id || '',
},
data: {
config: {
gcp: {
// Deployment region & project ID are immutable in the UI, but the
// Updatable GCP DTO requires all three fields to be sent.
deploymentRegion: accountConfig.deployment_region,
deploymentProjectId: accountConfig.deployment_project_id,
projectIds: values.projectIds || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
project_ids: values.projectIds || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
void logEvent('GCP Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
projectIds: nextConfig.project_ids,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, accountConfig, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
return isEqual(
[...(projectIds || [])].sort(),
[...accountConfig.project_ids].sort(),
);
}, [accountConfig, projectIds]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
};
}

View File

@@ -0,0 +1,165 @@
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,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import useAxiosError from 'hooks/useAxiosError';
import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useCloudAccountSetupDrawer({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const clearSubmitError = useCallback((): void => {
setSubmitError(null);
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.GCP,
},
{
query: {
onError: handleError,
},
},
);
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 || [],
},
},
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
// their own (validated non-empty), so their form values are used.
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
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,
});
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 });
} 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',
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -1,4 +1,4 @@
import { MouseEventHandler } from 'react';
import { MouseEvent } from 'react';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -11,7 +11,7 @@ export type UseCopyLogLink = {
isHighlighted: boolean;
isLogsExplorerPage: boolean;
activeLogId: string | null;
onLogCopy: MouseEventHandler<HTMLElement>;
onLogCopy: (event?: MouseEvent<HTMLElement>) => void;
onClearActiveLog: () => void;
};

View File

@@ -1,10 +1,4 @@
import {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
@@ -46,14 +40,14 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
[pathname],
);
const onLogCopy: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
const onLogCopy = useCallback(
(event?: MouseEvent<HTMLElement>): void => {
if (!logId) {
return;
}
event.preventDefault();
event.stopPropagation();
event?.preventDefault();
event?.stopPropagation();
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
@@ -66,7 +60,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
toast.success('Copied to clipboard', { position: 'bottom-right' });
},
[logId, urlQuery, minTime, maxTime, pathname, setCopy],
);

View File

@@ -1,16 +1,13 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useCreateUserRole,
useDeleteUserRole,
useGetUser,
} from 'api/generated/services/users';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
@@ -33,31 +30,34 @@ export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const queryClient = useQueryClient();
const { data, isLoading } = useGetRolesByUserID(
const { data, isLoading } = useGetUser(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const userRoles = useMemo(
() => data?.data?.userRoles ?? [],
[data?.data?.userRoles],
);
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
() => userRoles.map((userRole) => userRole.role),
[userRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
[userId, queryClient],
// DELETE /api/v2/user_roles/{id} is keyed by the user_role join row, not the role.
const assignmentIdByRoleId = useMemo(
() => new Map(userRoles.map((userRole) => [userRole.roleId, userRole.id])),
[userRoles],
);
const { mutateAsync: createUserRole } = useCreateUserRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteUserRole } = useDeleteUserRole({
mutation: { retry: retryOn429 },
});
const applyDiff = useCallback(
async (
localRoleIds: string[],
@@ -80,30 +80,33 @@ export function useMemberRoleManager(
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof removeRole> =>
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
run: (): ReturnType<typeof createUserRole> =>
createUserRole({ data: { userId, roleId: role.id ?? '' } }),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteUserRole> =>
deleteUserRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -113,7 +116,6 @@ export function useMemberRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -121,7 +123,7 @@ export function useMemberRoleManager(
return failures;
},
[userId, currentRoles, setRole, removeRole, invalidateRoles],
[userId, currentRoles, assignmentIdByRoleId, createUserRole, deleteUserRole],
);
return { currentRoles, isLoading, applyDiff };

View File

@@ -139,27 +139,6 @@ export const handlers = [
return res(ctx.status(500));
},
),
rest.get('http://localhost/api/v1/loginPrecheck', (req, res, ctx) => {
const email = req.url.searchParams.get('email');
if (email === 'failEmail@signoz.io') {
return res(ctx.status(500));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
sso: true,
ssoUrl: '',
canSelfRegister: false,
isUser: true,
ssoError: '',
},
}),
);
}),
rest.get('http://localhost/api/v2/licenses', (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessResponse)),
),
@@ -194,14 +173,6 @@ export const handlers = [
}),
),
),
rest.put('http://localhost/api/v1/user/:id', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: 'user updated successfully',
}),
),
),
rest.get(
'http://localhost/api/v3/autocomplete/aggregate_attributes',
(req, res, ctx) =>

View File

@@ -16,6 +16,8 @@ export interface CopyButtonProps {
/** Extra class merged onto the button. */
className?: string;
testId?: string;
/** Called after the copy is triggered (e.g. to show a toast). */
onCopy?: () => void;
}
/**
@@ -29,6 +31,7 @@ function CopyButton({
ariaLabel = 'Copy',
className,
testId,
onCopy,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyButton();
@@ -36,8 +39,9 @@ function CopyButton({
(e: MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
copyToClipboard(value);
onCopy?.();
},
[copyToClipboard, value],
[copyToClipboard, value, onCopy],
);
const stackStyle: CSSProperties = { width: size, height: size };
@@ -65,6 +69,7 @@ CopyButton.defaultProps = {
ariaLabel: 'Copy',
className: undefined,
testId: undefined,
onCopy: undefined,
};
export default CopyButton;

View File

@@ -1,12 +0,0 @@
import { User } from 'types/reducer/app';
export interface UserProps {
name: User['displayName'];
email: User['email'];
role: string;
frontendBaseUrl: string;
}
export interface UsersProps {
invites: UserProps[];
}

View File

@@ -151,6 +151,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -27,22 +27,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/invite/bulk", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateBulkInvite), handler.OpenAPIDef{
ID: "CreateBulkInvite",
Tags: []string{"users"},
Summary: "Create bulk invite",
Description: "This endpoint creates a bulk invite for a user",
Request: new(types.PostableBulkInviteRequest),
RequestContentType: "application/json",
Response: nil,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsersDeprecated), handler.OpenAPIDef{
ID: "ListUsersDeprecated",
Tags: []string{"users"},
@@ -145,23 +129,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authzMiddleware.SelfAccess(provider.userHandler.GetUserDeprecated), handler.OpenAPIDef{
ID: "GetUserDeprecated",
Tags: []string{"users"},
Summary: "Get user",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(types.DeprecatedUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
@@ -179,23 +146,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authzMiddleware.SelfAccess(provider.userHandler.UpdateUserDeprecated), handler.OpenAPIDef{
ID: "UpdateUserDeprecated",
Tags: []string{"users"},
Summary: "Update user",
Description: "This endpoint updates the user by id",
Request: new(types.DeprecatedUser),
RequestContentType: "application/json",
Response: new(types.DeprecatedUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
@@ -213,23 +163,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/user/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUserDeprecated",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
@@ -316,7 +249,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
}
if err := router.Handle("/api/v1/resetPassword", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
ID: "ResetPassword",
ID: "ResetPasswordDeprecated",
Tags: []string{"users"},
Summary: "Reset password",
Description: "This endpoint resets the password by token",
@@ -326,7 +259,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
Deprecated: true,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -366,6 +299,23 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/factor_password/reset", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
ID: "ResetPassword",
Tags: []string{"users"},
Summary: "Reset password",
Description: "This endpoint resets the password using a single use reset password token",
Request: new(types.PostableResetPassword),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
const (
@@ -151,40 +150,6 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
})
}
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
req.Context(),
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err != nil {
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
}
next(rw, req)
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
"logs": false
},
"dataCollected": {
"metrics": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
"logs": false
},
"dataCollected": {
"metrics": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
"logs": false
},
"dataCollected": {
"metrics": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
"logs": false
},
"dataCollected": {
"metrics": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
"logs": false
},
"dataCollected": {
"metrics": [

View File

@@ -84,20 +84,38 @@ func buildClusterRecords(
return records
}
// getTopClusterGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
// to intersect all).
func (m *module) getTopClusterGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableClusters,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
nodeConditionCounts map[string]nodeConditionCounts
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness []inframonitoringtypes.NodeCondition
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status / node readiness, resolve the full-scope
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -106,12 +124,37 @@ func (m *module) getTopClusterGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if len(filterByNodeReadiness) != 0 {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
return err
})
}
if orderByKey == inframonitoringtypes.ClusterNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, nil, err
}
// Secondary filter: keep only status/readiness-matching groups. A missing
// metric yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
if len(filterByNodeReadiness) != 0 {
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ClusterNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
}
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
@@ -157,10 +200,23 @@ func (m *module) getTopClusterGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status/readiness
// keyset. A missing metric yields an empty keyset, correctly emptying the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
if len(filterByNodeReadiness) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
}
func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
@@ -170,5 +226,9 @@ func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -139,20 +139,34 @@ func buildContainerRecords(
return records
}
// getTopContainerGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope container-status keyset when filtering, to intersect both).
func (m *module) getTopContainerGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableContainers,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]containerStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]containerStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByContainerStatus []inframonitoringtypes.ContainerStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by container status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByContainerStatus = req.Filter.FilterByContainerStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -161,12 +175,26 @@ func (m *module) getTopContainerGroupsAndMetadata(
return err
})
if len(filterByContainerStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByContainerStatus)
return err
})
}
if orderByKey == inframonitoringtypes.ContainerNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByContainerStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ContainerNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToContainersQueryNames[orderByKey]
@@ -212,10 +240,19 @@ func (m *module) getTopContainerGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByContainerStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (map[string]map[string]string, error) {
@@ -225,7 +262,11 @@ func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UU
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, containersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, containersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}
// getPerGroupContainerStatusCountsWithReqMetricChecks gates
@@ -241,6 +282,7 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByContainerStatus []inframonitoringtypes.ContainerStatus,
) (map[string]containerStatusCounts, *qbtypes.QueryWarnData, error) {
present, err := m.getMetricsExistence(ctx, containerStatusMetricNamesList)
if err != nil {
@@ -266,13 +308,28 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
return map[string]containerStatusCounts{}, warning, nil
}
counts, err := m.getPerGroupContainerStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
counts, err := m.getPerGroupContainerStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups, filterByContainerStatus)
if err != nil {
return nil, nil, err
}
return counts, nil, nil
}
// applyContainerStatusFilter adds the display-status push-down (lower(display_status)
// IN (...)) to the outer count builder. valuer lowercases the wire value while
// display_status is kubectl-cased, so we compare lower() on both. No-op when the
// requested set is empty.
func applyContainerStatusFilter(cb *sqlbuilder.SelectBuilder, filterByContainerStatus []inframonitoringtypes.ContainerStatus) {
if len(filterByContainerStatus) == 0 {
return
}
vals := make([]string, len(filterByContainerStatus))
for i, c := range filterByContainerStatus {
vals[i] = c.StringValue()
}
cb.Where(cb.In("lower(display_status)", sqlbuilder.List(vals)))
}
// getPerGroupContainerStatusCounts computes per-group counts of distinct
// containers bucketed by their latest kubectl-style display status in window.
// Caller must ensure the required metrics exist
@@ -297,8 +354,11 @@ func (m *module) getPerGroupContainerStatusCounts(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByContainerStatus []inframonitoringtypes.ContainerStatus,
) (map[string]containerStatusCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
// Empty pageGroups means "span all under user filter", allowed only in
// full-scope mode (filtering by status). Otherwise it's an empty page.
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByContainerStatus) == 0) {
return map[string]containerStatusCounts{}, nil
}
@@ -482,11 +542,15 @@ func (m *module) getPerGroupContainerStatusCounts(
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols, statusCountCols...)
countSQL := fmt.Sprintf(
"SELECT %s FROM container_status GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
// Outer count query. Built with sqlbuilder so the status push-down uses a
// proper IN (keep only containers whose display status is in the requested set).
countBuilder := sqlbuilder.NewSelectBuilder()
countBuilder.Select(countSelectCols...)
countBuilder.From("container_status")
applyContainerStatusFilter(countBuilder, filterByContainerStatus)
countBuilder.GroupBy(countGroupBy...)
countSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
cteFragments := []string{
@@ -499,7 +563,7 @@ func (m *module) getPerGroupContainerStatusCounts(
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
finalArgs := querybuilder.PrependArgs([][]any{
stateFpsArgs, containerStateArgs, reasonFpsArgs, reasonInnerArgs,
}, nil)
}, countArgs)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {

View File

@@ -0,0 +1,59 @@
package implinframonitoring
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
)
func TestApplyContainerStatusFilter(t *testing.T) {
tests := []struct {
name string
statuses []inframonitoringtypes.ContainerStatus
wantWhere bool
wantArgs []any
}{
{
name: "empty set yields no clause",
statuses: nil,
wantWhere: false,
wantArgs: nil,
},
{
name: "single status pushes lowercased arg via IN",
statuses: []inframonitoringtypes.ContainerStatus{inframonitoringtypes.ContainerStatusRunning},
wantWhere: true,
wantArgs: []any{"running"},
},
{
name: "multiple statuses push lowercased args via IN",
statuses: []inframonitoringtypes.ContainerStatus{
inframonitoringtypes.ContainerStatusRunning,
inframonitoringtypes.ContainerStatusCrashLoopBackOff,
},
wantWhere: true,
wantArgs: []any{"running", "crashloopbackoff"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cb := sqlbuilder.NewSelectBuilder()
cb.Select("pod_uid")
cb.From("container_status")
applyContainerStatusFilter(cb, tt.statuses)
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
hasWhere := strings.Contains(sql, "lower(display_status) IN (")
assert.Equal(t, tt.wantWhere, hasWhere)
if len(tt.wantArgs) == 0 {
assert.Empty(t, args)
} else {
assert.Equal(t, tt.wantArgs, args)
}
})
}
}

View File

@@ -90,20 +90,34 @@ func buildDaemonSetRecords(
return records
}
// getTopDaemonSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopDaemonSetGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableDaemonSets,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -112,12 +126,26 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.DaemonSetNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DaemonSetNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToDaemonSetsQueryNames[orderByKey]
@@ -163,10 +191,19 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
@@ -176,5 +213,9 @@ func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UU
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -82,20 +82,34 @@ func buildDeploymentRecords(
return records
}
// getTopDeploymentGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopDeploymentGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableDeployments,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -104,12 +118,26 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.DeploymentNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DeploymentNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToDeploymentsQueryNames[orderByKey]
@@ -155,10 +183,19 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
@@ -168,5 +205,9 @@ func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.U
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -63,6 +63,33 @@ func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByK
return compositeKeyFromList(parts)
}
// intersectMap returns the entries of m whose key is present in keep (a new
// map). keep's value type is irrelevant — only its keys are read — so a
// per-group counts map (already filtered by the SQL push-down) can be passed
// directly. Used to trim metadataMap to the status-matching groups.
func intersectMap[V any, K any](m map[string]V, keep map[string]K) map[string]V {
out := make(map[string]V, len(m))
for k, v := range m {
if _, ok := keep[k]; ok {
out[k] = v
}
}
return out
}
// intersectRankedGroups returns the ranked groups whose compositeKey is present
// in keep, preserving order. Keeps status-unmatched groups out of the ranked
// page slots.
func intersectRankedGroups[K any](groups []rankedGroup, keep map[string]K) []rankedGroup {
out := make([]rankedGroup, 0, len(groups))
for _, g := range groups {
if _, ok := keep[g.compositeKey]; ok {
out = append(out, g)
}
}
return out
}
// parseAndSortGroups extracts group label maps from a ScalarData response and
// sorts them by the ranking query's aggregation value.
func parseAndSortGroups(
@@ -850,8 +877,10 @@ func (m *module) getPerGroupDistinctCounts(
valueExpr = fmt.Sprintf("(%s)", strings.Join(parts, ", "))
}
// Prefix the alias so it never collides with a groupBy col alias
// (e.g. clusters grouped by k8s.node.name, which is also counted).
selectCols = append(selectCols,
fmt.Sprintf("uniqExactIf(%s, %s != '') AS %s", valueExpr, extract, quoteIdentifier(attr)),
fmt.Sprintf("uniqExactIf(%s, %s != '') AS %s", valueExpr, extract, quoteIdentifier(fmt.Sprintf("__count_%s", attr))),
)
}
sb.Select(selectCols...)

View File

@@ -5,6 +5,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
)
func groupByKey(name string) qbtypes.GroupByKey {
@@ -88,10 +89,7 @@ func TestIsKeyInGroupByAttrs(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
if got != tt.expectedFound {
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
tt.groupByAttrs, tt.key, got, tt.expectedFound)
}
assert.Equal(t, tt.expectedFound, got)
})
}
}
@@ -156,10 +154,7 @@ func TestMergeFilterExpressions(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
if got != tt.expected {
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
}
assert.Equal(t, tt.expected, got)
})
}
}
@@ -205,10 +200,7 @@ func TestCompositeKeyFromList(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := compositeKeyFromList(tt.parts)
if got != tt.expected {
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
tt.parts, got, tt.expected)
}
assert.Equal(t, tt.expected, got)
})
}
}
@@ -376,10 +368,81 @@ func TestCompositeKeyFromLabels(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
if got != tt.expected {
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
tt.labels, tt.groupBy, got, tt.expected)
}
assert.Equal(t, tt.expected, got)
})
}
}
func TestIntersectMap(t *testing.T) {
tests := []struct {
name string
m map[string]int
keep map[string]podStatusCounts
expected map[string]int
}{
{
name: "keep subset",
m: map[string]int{"a": 1, "b": 2, "c": 3},
keep: map[string]podStatusCounts{"a": {}, "c": {}},
expected: map[string]int{"a": 1, "c": 3},
},
{
name: "empty keep drops everything",
m: map[string]int{"a": 1, "b": 2},
keep: map[string]podStatusCounts{},
expected: map[string]int{},
},
{
name: "keep key absent from m is ignored",
m: map[string]int{"a": 1},
keep: map[string]podStatusCounts{"a": {}, "z": {}},
expected: map[string]int{"a": 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectMap(tt.m, tt.keep)
assert.Equal(t, tt.expected, got)
})
}
}
func TestIntersectRankedGroups(t *testing.T) {
groups := []rankedGroup{
{compositeKey: "a", value: 3},
{compositeKey: "b", value: 2},
{compositeKey: "c", value: 1},
}
tests := []struct {
name string
groups []rankedGroup
keep map[string]podStatusCounts
expected []string // compositeKeys in order
}{
{
name: "preserves order, drops non-matching",
groups: groups,
keep: map[string]podStatusCounts{"a": {}, "c": {}},
expected: []string{"a", "c"},
},
{
name: "empty keep drops all",
groups: groups,
keep: map[string]podStatusCounts{},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectRankedGroups(tt.groups, tt.keep)
gotKeys := make([]string, 0, len(got))
for _, g := range got {
gotKeys = append(gotKeys, g.compositeKey)
}
assert.Equal(t, tt.expected, gotKeys)
})
}
}

View File

@@ -90,20 +90,34 @@ func buildJobRecords(
return records
}
// getTopJobGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopJobGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableJobs,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -112,12 +126,26 @@ func (m *module) getTopJobGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.JobNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.JobNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToJobsQueryNames[orderByKey]
@@ -163,10 +191,19 @@ func (m *module) getTopJobGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
@@ -176,5 +213,9 @@ func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, re
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -286,11 +286,36 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
pageGroups, metadataMap, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
podFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
restartCounts map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
podFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopPodGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, statusCounts, statusWarning, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && statusWarning != nil {
resp.Warning = statusWarning
resp.Records = []inframonitoringtypes.PodRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -298,20 +323,8 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -321,14 +334,18 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups)
return err
})
// When filtering, statusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -379,11 +396,37 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
pageGroups, metadataMap, err := m.getTopContainerGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
containerFilter *qbtypes.Filter
filterByContainerStatus []inframonitoringtypes.ContainerStatus
queryResp *qbtypes.QueryRangeResponse
restartCounts map[string]int64
readyCounts map[string]containerReadyCounts
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
containerFilter = &req.Filter.Filter
filterByContainerStatus = req.Filter.FilterByContainerStatus
}
// getTopContainerGroupsAndMetadata fetches metadata + ranking (+ full-scope
// container status when filtering) concurrently, intersecting metadata/ranked
// groups against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, statusCounts, statusWarning, err := m.getTopContainerGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByContainerStatus) != 0 && statusWarning != nil {
resp.Warning = statusWarning
resp.Records = []inframonitoringtypes.ContainerRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -391,21 +434,8 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newContainersTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
statusCounts map[string]containerStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
readyCounts map[string]containerReadyCounts
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -415,19 +445,23 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups)
return err
})
// When filtering, statusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByContainerStatus) == 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -478,11 +512,37 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return resp, nil
}
pageGroups, metadataMap, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
nodeFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness []inframonitoringtypes.NodeCondition
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
nodeFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
// getTopNodeGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status / node readiness when filtering) concurrently, intersecting
// metadata/ranked groups against the keysets. It returns the keysets + warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCounts, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -490,20 +550,8 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -511,16 +559,24 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering by readiness, nodeConditionCounts already holds the full-scope
// map (a superset of the page); otherwise compute it page-scoped here.
if len(filterByNodeReadiness) == 0 {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, nil)
return err
})
}
// When filtering by pod status, podStatusCounts already holds the full-scope
// map; otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -571,11 +627,36 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
pageGroups, metadataMap, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
namespaceFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
resourceCounts map[string]map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
namespaceFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopNamespaceGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.NamespaceRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -583,20 +664,8 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -606,14 +675,18 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -663,11 +736,39 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
return resp, nil
}
pageGroups, metadataMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
clusterFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness []inframonitoringtypes.NodeCondition
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
resourceCounts map[string]map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
clusterFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
// getTopClusterGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status / node readiness when filtering) concurrently, intersecting
// metadata/ranked groups against the keysets. It returns the keysets + warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCountsMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -675,23 +776,8 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -699,21 +785,29 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering by readiness, nodeConditionCountsMap already holds the
// full-scope map (a superset of the page); otherwise compute it page-scoped here.
if len(filterByNodeReadiness) == 0 {
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
return err
})
// When filtering by pod status, podStatusCounts already holds the full-scope
// map; otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -827,7 +921,7 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
// Bake the deployments base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
req.Filter = &inframonitoringtypes.DeploymentFilter{}
}
req.Filter.Expression = mergeFilterExpressions(deploymentsBaseFilterExpr, req.Filter.Expression)
@@ -842,11 +936,35 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
return resp, nil
}
pageGroups, metadataMap, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
deploymentFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
deploymentFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopDeploymentGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.DeploymentRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -854,19 +972,8 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -874,11 +981,15 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, deploymentFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -919,7 +1030,7 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
req.Filter = &inframonitoringtypes.StatefulSetFilter{}
}
req.Filter.Expression = mergeFilterExpressions(statefulSetsBaseFilterExpr, req.Filter.Expression)
@@ -934,11 +1045,35 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return resp, nil
}
pageGroups, metadataMap, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
statefulSetFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
statefulSetFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopStatefulSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.StatefulSetRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -946,21 +1081,8 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -968,11 +1090,15 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, statefulSetFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -1013,7 +1139,7 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
// Bake the jobs base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
req.Filter = &inframonitoringtypes.JobFilter{}
}
req.Filter.Expression = mergeFilterExpressions(jobsBaseFilterExpr, req.Filter.Expression)
@@ -1028,11 +1154,35 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
pageGroups, metadataMap, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
jobFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
jobFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopJobGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.JobRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -1040,21 +1190,8 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -1062,11 +1199,15 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, jobFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -1107,7 +1248,7 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
req.Filter = &inframonitoringtypes.DaemonSetFilter{}
}
req.Filter.Expression = mergeFilterExpressions(daemonSetsBaseFilterExpr, req.Filter.Expression)
@@ -1122,11 +1263,35 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
pageGroups, metadataMap, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
var (
filterExpr string
daemonSetFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
daemonSetFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopDaemonSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.DaemonSetRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -1134,21 +1299,8 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -1156,11 +1308,15 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, daemonSetFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err

View File

@@ -65,20 +65,34 @@ func buildNamespaceRecords(
return records
}
// getTopNamespaceGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopNamespaceGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNamespaces,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -87,12 +101,26 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.NamespaceNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NamespaceNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToNamespacesQueryNames[orderByKey]
@@ -138,10 +166,19 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
@@ -151,5 +188,9 @@ func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UU
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
@@ -92,20 +91,38 @@ func buildNodeRecords(
return records
}
// getTopNodeGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
// to intersect all).
func (m *module) getTopNodeGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNodes,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
nodeConditionCounts map[string]nodeConditionCounts
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness []inframonitoringtypes.NodeCondition
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status / node readiness, resolve the full-scope
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -114,12 +131,37 @@ func (m *module) getTopNodeGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if len(filterByNodeReadiness) != 0 {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
return err
})
}
if orderByKey == inframonitoringtypes.NodeNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, nil, err
}
// Secondary filter: keep only status/readiness-matching groups. A missing
// metric yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
if len(filterByNodeReadiness) != 0 {
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NodeNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
}
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
@@ -165,10 +207,23 @@ func (m *module) getTopNodeGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status/readiness
// keyset. A missing metric yields an empty keyset, correctly emptying the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
if len(filterByNodeReadiness) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
}
func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
@@ -178,7 +233,11 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
@@ -192,6 +251,24 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
//
// Groups absent from the result map have implicit zero counts (caller default).
// applyNodeReadinessFilter adds the readiness push-down (condition_value IN (...))
// to the outer count builder. condition_value is numeric (1=Ready, 0=NotReady), so
// we map each requested enum to its int. No-op when the requested set is empty.
func applyNodeReadinessFilter(cb *sqlbuilder.SelectBuilder, filterByNodeReadiness []inframonitoringtypes.NodeCondition) {
if len(filterByNodeReadiness) == 0 {
return
}
nums := make([]int, len(filterByNodeReadiness))
for i, c := range filterByNodeReadiness {
v := inframonitoringtypes.NodeConditionNumNotReady
if c == inframonitoringtypes.NodeConditionReady {
v = inframonitoringtypes.NodeConditionNumReady
}
nums[i] = v
}
cb.Where(cb.In("condition_value", sqlbuilder.List(nums)))
}
func (m *module) getPerGroupNodeConditionCounts(
ctx context.Context,
orgID valuer.UUID,
@@ -199,8 +276,11 @@ func (m *module) getPerGroupNodeConditionCounts(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByNodeReadiness []inframonitoringtypes.NodeCondition,
) (map[string]nodeConditionCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
// Empty pageGroups means "span all under user filter", allowed only in
// full-scope mode (filtering by readiness). Otherwise it's an empty page.
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByNodeReadiness) == 0) {
return map[string]nodeConditionCounts{}, nil
}
@@ -288,11 +368,14 @@ func (m *module) getPerGroupNodeConditionCounts(
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
)
countNodesPerConditionSQL := fmt.Sprintf(
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
strings.Join(countNodesPerConditionSelectCols, ", "),
strings.Join(countNodesPerConditionGroupBy, ", "),
)
// Outer count query. Built with sqlbuilder so the readiness push-down uses a
// proper IN (keep only nodes whose readiness is in the requested set).
countBuilder := sqlbuilder.NewSelectBuilder()
countBuilder.Select(countNodesPerConditionSelectCols...)
countBuilder.From("latest_condition_per_node")
applyNodeReadinessFilter(countBuilder, filterByNodeReadiness)
countBuilder.GroupBy(countNodesPerConditionGroupBy...)
countNodesPerConditionSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
// Combine CTEs + outer.
cteFragments := []string{
@@ -300,7 +383,7 @@ func (m *module) getPerGroupNodeConditionCounts(
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, countArgs)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {

View File

@@ -0,0 +1,65 @@
package implinframonitoring
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
)
func TestApplyNodeReadinessFilter(t *testing.T) {
tests := []struct {
name string
readiness []inframonitoringtypes.NodeCondition
wantWhere bool
wantArgs []any
}{
{
name: "empty set yields no clause",
readiness: nil,
wantWhere: false,
wantArgs: nil,
},
{
name: "ready maps to 1 via IN",
readiness: []inframonitoringtypes.NodeCondition{inframonitoringtypes.NodeConditionReady},
wantWhere: true,
wantArgs: []any{inframonitoringtypes.NodeConditionNumReady},
},
{
name: "not_ready maps to 0 via IN",
readiness: []inframonitoringtypes.NodeCondition{inframonitoringtypes.NodeConditionNotReady},
wantWhere: true,
wantArgs: []any{inframonitoringtypes.NodeConditionNumNotReady},
},
{
name: "multiple conditions map to their ints via IN",
readiness: []inframonitoringtypes.NodeCondition{
inframonitoringtypes.NodeConditionReady,
inframonitoringtypes.NodeConditionNotReady,
},
wantWhere: true,
wantArgs: []any{inframonitoringtypes.NodeConditionNumReady, inframonitoringtypes.NodeConditionNumNotReady},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cb := sqlbuilder.NewSelectBuilder()
cb.Select("node_name")
cb.From("latest_condition_per_node")
applyNodeReadinessFilter(cb, tt.readiness)
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
hasWhere := strings.Contains(sql, "condition_value IN (")
assert.Equal(t, tt.wantWhere, hasWhere)
if len(tt.wantArgs) == 0 {
assert.Empty(t, args)
} else {
assert.Equal(t, tt.wantArgs, args)
}
})
}
}

View File

@@ -146,24 +146,34 @@ func buildPodRecords(
return records
}
// getTopPodGroupsAndMetadata fetches the group metadata and the ordering-metric
// ranking concurrently, then pages the ranked groups, backfilling from metadata
// when the page extends past the metric-ranked groups. Returns the page of
// groups and the metadata map (needed by the caller for Total and records).
// getTopPodGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopPodGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostablePods,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -172,12 +182,26 @@ func (m *module) getTopPodGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.PodNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.PodNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToPodsQueryNames[orderByKey]
@@ -223,10 +247,19 @@ func (m *module) getTopPodGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
@@ -236,7 +269,11 @@ func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, re
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}
// getPerGroupPodStatusCountsWithReqMetricChecks gates getPerGroupPodStatusCounts
@@ -251,6 +288,7 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByPodStatus []inframonitoringtypes.PodStatus,
) (map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
present, err := m.getMetricsExistence(ctx, podStatusMetricNamesList)
if err != nil {
@@ -276,13 +314,28 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
return map[string]podStatusCounts{}, warning, nil
}
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups, filterByPodStatus)
if err != nil {
return nil, nil, err
}
return counts, nil, nil
}
// applyPodStatusFilter adds the display-status push-down (lower(display_status)
// IN (...)) to the outer count builder. valuer lowercases the wire value while
// display_status is kubectl-cased, so we compare lower() on both. No-op when the
// requested set is empty.
func applyPodStatusFilter(cb *sqlbuilder.SelectBuilder, filterByPodStatus []inframonitoringtypes.PodStatus) {
if len(filterByPodStatus) == 0 {
return
}
vals := make([]string, len(filterByPodStatus))
for i, s := range filterByPodStatus {
vals[i] = s.StringValue()
}
cb.Where(cb.In("lower(display_status)", sqlbuilder.List(vals)))
}
// getPerGroupPodStatusCounts computes per-group pod counts bucketed by each
// pod's latest kubectl-style display status in the requested window. Caller
// must ensure the required metrics exist (getPerGroupPodStatusCountsWithReqMetricChecks).
@@ -303,13 +356,20 @@ func (m *module) getPerGroupPodStatusCounts(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByPodStatus []inframonitoringtypes.PodStatus,
) (map[string]podStatusCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
// return early if no group by or (no pagegroups provided plus no filterBystatus given for a full scan)
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByPodStatus) == 0) {
return map[string]podStatusCounts{}, nil
}
var (
filterClause *sqlbuilder.WhereClause
err error
userFilterExpr string
)
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
@@ -322,10 +382,7 @@ func (m *module) getPerGroupPodStatusCounts(
// CTEs, and buildFilterClause hits the metadata store + parses the
// expression, so we don't want to repeat it per CTE. AddWhereClause only
// reads the clause, so the same instance is safe to attach to each builder.
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
@@ -540,11 +597,15 @@ func (m *module) getPerGroupPodStatusCounts(
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols, statusCountCols...)
countSQL := fmt.Sprintf(
"SELECT %s FROM pod_status GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
// Outer count query. Built with sqlbuilder so the status push-down uses a
// proper IN (keep only pods whose display status is in the requested set).
countBuilder := sqlbuilder.NewSelectBuilder()
countBuilder.Select(countSelectCols...)
countBuilder.From("pod_status")
applyPodStatusFilter(countBuilder, filterByPodStatus)
countBuilder.GroupBy(countGroupBy...)
countSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
cteFragments := []string{
@@ -561,7 +622,7 @@ func (m *module) getPerGroupPodStatusCounts(
phaseFpsArgs, phasePerPodArgs,
podReasonFpsArgs, podReasonPerPodArgs,
containerReasonFpsArgs, containerInnerArgs,
}, nil)
}, countArgs)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {

View File

@@ -0,0 +1,59 @@
package implinframonitoring
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
)
func TestApplyPodStatusFilter(t *testing.T) {
tests := []struct {
name string
statuses []inframonitoringtypes.PodStatus
wantWhere bool
wantArgs []any
}{
{
name: "empty set yields no clause",
statuses: nil,
wantWhere: false,
wantArgs: nil,
},
{
name: "single status pushes lowercased arg via IN",
statuses: []inframonitoringtypes.PodStatus{inframonitoringtypes.PodStatusRunning},
wantWhere: true,
wantArgs: []any{"running"},
},
{
name: "multiple statuses push lowercased args via IN",
statuses: []inframonitoringtypes.PodStatus{
inframonitoringtypes.PodStatusRunning,
inframonitoringtypes.PodStatusCrashLoopBackOff,
},
wantWhere: true,
wantArgs: []any{"running", "crashloopbackoff"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cb := sqlbuilder.NewSelectBuilder()
cb.Select("pod_uid")
cb.From("pod_status")
applyPodStatusFilter(cb, tt.statuses)
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
hasWhere := strings.Contains(sql, "lower(display_status) IN (")
assert.Equal(t, tt.wantWhere, hasWhere)
if len(tt.wantArgs) == 0 {
assert.Empty(t, args)
} else {
assert.Equal(t, tt.wantArgs, args)
}
})
}
}

View File

@@ -82,20 +82,34 @@ func buildStatefulSetRecords(
return records
}
// getTopStatefulSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopStatefulSetGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableStatefulSets,
) ([]map[string]string, map[string]map[string]string, error) {
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -104,12 +118,26 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.StatefulSetNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.StatefulSetNameAttrKey)
return pageGroups, metadataMap, nil
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
queryNamesForOrderBy := orderByToStatefulSetsQueryNames[orderByKey]
@@ -155,10 +183,19 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, nil, nil, nil, err
}
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
}
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
@@ -168,5 +205,9 @@ func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
}

View File

@@ -88,58 +88,6 @@ func (handler *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusCreated, invites[0])
}
func (handler *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var req types.PostableBulkInviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
// Validate that the request contains users
if len(req.Invites) == 0 {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "no invites provided for invitation"))
return
}
_, err = handler.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, nil)
}
func (handler *handler) GetUserDeprecated(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id := mux.Vars(r)["id"]
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
user, err := handler.getter.GetDeprecatedUserByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, user)
}
func (handler *handler) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -284,33 +232,6 @@ func (handler *handler) ListUsers(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusOK, users)
}
func (handler *handler) UpdateUserDeprecated(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id := mux.Vars(r)["id"]
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
user := types.DeprecatedUser{User: &types.User{}}
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
render.Error(w, err)
return
}
updatedUser, err := handler.setter.UpdateUserDeprecated(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, updatedUser)
}
func (handler *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -470,7 +391,7 @@ func (handler *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
defer cancel()
req := new(types.PostableResetPassword)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(w, err)
return
}

View File

@@ -276,92 +276,6 @@ func (module *setter) CreatePendingInviteUser(ctx context.Context, identityID va
return user, nil
}
func (module *setter) UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
existingUser, err := module.getter.GetDeprecatedUserByOrgIDAndID(ctx, orgID, valuer.MustNewUUID(id))
if err != nil {
return nil, err
}
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update root user")
}
if err := existingUser.ErrIfDeleted(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
}
roleChange := user.Role != "" && user.Role != existingUser.Role
if roleChange {
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = module.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err != nil {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
}
// make sure the user is not demoting self from admin
if roleChange && existingUser.ID == valuer.MustNewUUID(claims.IdentityID()) && existingUser.Role == types.RoleAdmin && user.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot change self role")
}
if roleChange {
err = module.authz.ModifyGrant(ctx,
orgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role)},
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(coretypes.NewResourceUser(), id, orgID, nil),
)
if err != nil {
return nil, err
}
}
existingUser.Update(user.DisplayName, user.Role)
// update the user - idempotent (this does analytics too so keeping it outside txn)
if err := module.UpdateAnyUserDeprecated(ctx, orgID, existingUser); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
if roleChange {
// delete old role entries and create new ones
if err := module.userRoleStore.DeleteUserRoles(ctx, existingUser.ID); err != nil {
return err
}
// create new ones
if err := module.createUserRoleEntries(ctx, existingUser.OrgID, existingUser.ID, []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return existingUser, nil
}
func (module *setter) UpdateUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, updatable *types.UpdatableUser) (*types.User, error) {
existingUser, err := module.getter.GetUserByOrgIDAndID(ctx, orgID, userID)
if err != nil {
@@ -401,23 +315,6 @@ func (module *setter) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user
return nil
}
func (module *setter) UpdateAnyUserDeprecated(ctx context.Context, orgID valuer.UUID, deprecateUser *types.DeprecatedUser) error {
user := types.NewUserFromDeprecatedUser(deprecateUser)
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
return err
}
traits := types.NewTraitsFromDeprecatedUser(deprecateUser)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traits)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Updated", traits)
if err := module.tokenizer.DeleteIdentity(ctx, user.ID); err != nil {
return err
}
return nil
}
func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error {
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(id))
if err != nil {

View File

@@ -34,11 +34,9 @@ type Setter interface {
// Initiate forgot password flow for a user
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error)
UpdateUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, updatable *types.UpdatableUser) (*types.User, error)
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
UpdateAnyUserDeprecated(ctx context.Context, orgID valuer.UUID, deprecateUser *types.DeprecatedUser) error
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
@@ -109,16 +107,13 @@ type Getter interface {
type Handler interface {
// invite
CreateInvite(http.ResponseWriter, *http.Request)
CreateBulkInvite(http.ResponseWriter, *http.Request)
// users
ListUsersDeprecated(http.ResponseWriter, *http.Request)
ListUsers(http.ResponseWriter, *http.Request)
CreateUser(http.ResponseWriter, *http.Request)
UpdateUserDeprecated(http.ResponseWriter, *http.Request)
UpdateUser(http.ResponseWriter, *http.Request)
DeleteUser(http.ResponseWriter, *http.Request)
GetUserDeprecated(http.ResponseWriter, *http.Request)
GetUser(http.ResponseWriter, *http.Request)
GetMyUserDeprecated(http.ResponseWriter, *http.Request)
GetMyUser(http.ResponseWriter, *http.Request)

View File

@@ -72,14 +72,6 @@ func (c *Claims) LogValue() slog.Value {
)
}
func (c *Claims) IsSelfAccess(id string) error {
if c.UserID == id {
return nil
}
return errors.NewForbiddenf(errors.CodeForbidden, "only the user/admin can access their own resource")
}
func (c *Claims) IdentityID() string {
if c.Principal == PrincipalUser {
return c.UserID

View File

@@ -42,13 +42,22 @@ type ClusterRecord struct {
type PostableClusters struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
Filter *ClusterFilter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// ClusterFilter is the attribute filter plus optional secondary filters on the
// derived pod display status(es) (see PodStatus; matches any listed, OR) and node
// readiness (see NodeCondition; matches any listed, OR). Empty FilterByPodStatus / FilterByNodeReadiness = off.
type ClusterFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
FilterByNodeReadiness []NodeCondition `json:"filterByNodeReadiness"`
}
// Validate ensures PostableClusters contains acceptable values.
func (req *PostableClusters) Validate() error {
if req == nil {
@@ -88,6 +97,19 @@ func (req *PostableClusters) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !s.IsFilterable() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
for _, c := range req.Filter.FilterByNodeReadiness {
if !c.IsFilterable() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by node readiness: %s", c)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(ClustersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -75,13 +75,21 @@ type ContainerRecord struct {
type PostableContainers struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
Filter *ContainerFilter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// ContainerFilter is the attribute filter plus an optional secondary filter on
// the derived container display status(es) (see ContainerStatus; matches any
// listed, OR). Empty FilterByContainerStatus = off.
type ContainerFilter struct {
qbtypes.Filter `json:",inline"`
FilterByContainerStatus []ContainerStatus `json:"filterByContainerStatus"`
}
// Validate ensures PostableContainers contains acceptable values.
func (req *PostableContainers) Validate() error {
if req == nil {
@@ -121,6 +129,14 @@ func (req *PostableContainers) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, c := range req.Filter.FilterByContainerStatus {
if !c.IsFilterable() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by container status: %s", c)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(ContainersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -1,6 +1,10 @@
package inframonitoringtypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"slices"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ContainerStatus is the kubectl-style display status of a container, derived
// from k8s.container.status.state (base) + k8s.container.status.reason (overlay).
@@ -31,6 +35,12 @@ var (
ContainerStatusNoData = ContainerStatus{valuer.NewString("no_data")}
)
// IsFilterable reports whether c is a concrete, user-filterable
// container status: any Enum() member except the no_data sentinel.
func (c ContainerStatus) IsFilterable() bool {
return c != ContainerStatusNoData && slices.Contains((ContainerStatus{}).Enum(), any(c))
}
func (ContainerStatus) Enum() []any {
return []any{
ContainerStatusRunning,

View File

@@ -36,13 +36,20 @@ type DaemonSetRecord struct {
type PostableDaemonSets struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
Filter *DaemonSetFilter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// DaemonSetFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type DaemonSetFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableDaemonSets contains acceptable values.
func (req *PostableDaemonSets) Validate() error {
if req == nil {
@@ -82,6 +89,14 @@ func (req *PostableDaemonSets) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !s.IsFilterable() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(DaemonSetsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

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