Compare commits

..

5 Commits

Author SHA1 Message Date
Srikanth Chekuri
b9f4fcd681 chore(telemetrytypes): introduce LogicalField (#12499)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
Possible options

1. The compatibility keys maps (the approach already in the code).

`backward_compat_keys.go` makes an alias key at metadata time. We
rejected this option because of evidence. The alias key resolves, but it
reads the wrong data. It prepares to `attributes_string['<alias>']`, and
that physical key does not hold the data.

2. The flat multi-key.

`GetKeys` returns multiple keys in order, and the downstream code uses
the list. The option fails on semantics. It removes one piece of
information that the downstream must have. The downstream must know the
difference between two cases:

- Two keys are the same field with two spellings so we can merge them
into one expression.
- Two keys are different fields with the same name. The condition
builder must make one condition for each key. The operator connects the
conditions.

Three failures show the problem:

- Negative operators connect with OR across the keys. A row that has
only one spelling then always matches. Example: `env != 'prod'` matches
each row that does not have one of the two keys.
- A row that has both spellings with different values gets no clear
result.
- A value position (group-by, select) needs exactly one expression for
one field. A flat list cannot point to that expression.

The information must live somewhere.

3. Annotations on `TelemetryFieldKey`

Maintain the `SemconvMembers` and `SemconvMaterializedColumns` fields on
the keys. The information is the same as in option 4. But it's awkward
because "these N keys are one family, in this order" lives in N copies,
one copy on each key.

4. introduce `LogicalField`

The information is the same as in option 3, but the structure holds it:

- The slice is the ambiguity.
- The group is the family.
- The member order is the precedence. The code sorts the members one
time, by family rank, at construction.
- The members point to the metadata entries. The code copies nothing and
changes nothing.
- The identity (signal, context, data type) is on the group. A merge
across contexts or data types is not possible. The design does not avoid
that merge; the design cannot express it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:40:37 +00:00
Aditya Singh
0dd9a156b9 feat(log-details): add highlights section to log details drawer [2/3] (#12425)
## Pull Request

---

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

**What it does:** Slice 2 of the log-details drawer revamp, a new
**Highlights** row at the
top of the drawer that surfaces a log's key fields as chips. Gated
behind `isLogDetailsV2`
(ships off). Stacked on the header PR (#12310 /
`feat/log-detail-revamp`); the DataViewer
lands in the next PR.

**Change points**

- New Highlight section added. Check screenshot
- Driven by config.
- Severity chip color
- Trace id click opens trace details page in new tab
- Tests updated


#### Screenshots / Screen Recordings (if applicable)
<img width="2158" height="834" alt="image"
src="https://github.com/user-attachments/assets/9b9af5b0-8437-4a6d-8b34-e97b0d55de26"
/>

<img width="2308" height="920" alt="image"
src="https://github.com/user-attachments/assets/69e3702f-cc25-4570-87d2-67a000925705"
/>



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

---

###  Change Type
_Select all that apply_

- [x]  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 12:05:35 +00:00
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
54 changed files with 2915 additions and 93 deletions

View File

@@ -81,24 +81,16 @@ devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
##############################################################
# go commands
##############################################################
SQLITE_PATH ?= signoz.db
HTTP_HOST_PORT ?= 0.0.0.0:8080
PRIVATE_HOST_PORT ?= 0.0.0.0:8085
OPAMP_WS_ENDPOINT ?= 0.0.0.0:4320
.PHONY: go-run-enterprise
go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SQLITE_PATH) \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
SIGNOZ_HTTP_HOST_PORT=$(HTTP_HOST_PORT) \
SIGNOZ_PRIVATE_HOST_PORT=$(PRIVATE_HOST_PORT) \
SIGNOZ_OPAMP_WS_ENDPOINT=$(OPAMP_WS_ENDPOINT) \
go run -race \
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go server
@@ -109,30 +101,16 @@ go-test: ## Runs go unit tests
.PHONY: go-run-community
go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SQLITE_PATH) \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
SIGNOZ_HTTP_HOST_PORT=$(HTTP_HOST_PORT) \
SIGNOZ_PRIVATE_HOST_PORT=$(PRIVATE_HOST_PORT) \
SIGNOZ_OPAMP_WS_ENDPOINT=$(OPAMP_WS_ENDPOINT) \
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
.PHONY: go-stop
go-stop: ## Stops the go backend server listening on HTTP_HOST_PORT
@PORT=$(lastword $(subst :, ,$(HTTP_HOST_PORT))); \
PIDS=$$(lsof -ti tcp:$$PORT); \
if [ -n "$$PIDS" ]; then \
kill $$PIDS; \
echo "Stopped signoz server on port $$PORT (pid $$PIDS)"; \
else \
echo "No signoz server running on port $$PORT"; \
fi
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community
go-build-community: $(GO_BUILD_ARCHS_COMMUNITY)

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

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

@@ -0,0 +1,46 @@
.highlights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 16px;
padding: 12px 0;
// Constrain each KeyValueLabel (the grid items) to its cell.
:global(.key-value-label) {
width: auto;
min-width: 0;
overflow: hidden;
}
}
.valueBadge {
--badge-font-size: 13px;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
}
// Truncating text inside a badge
.badgeText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.serviceDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
.traceLink {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-primary);
}

View File

@@ -0,0 +1,36 @@
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { ILog } from 'types/api/logs/log';
import { LOG_HIGHLIGHTS } from './config';
import styles from './LogHighlights.module.scss';
interface LogHighlightsProps {
log: ILog;
}
function LogHighlights({ log }: LogHighlightsProps): JSX.Element | null {
const fields = LOG_HIGHLIGHTS.map((field) => ({
key: field.key,
label: field.label,
value: field.render(log),
})).filter((field) => field.value != null);
if (fields.length === 0) {
return null;
}
return (
<div className={styles.highlights} data-testid="log-details-highlights">
{fields.map((field) => (
<KeyValueLabel
key={field.key}
badgeKey={field.label}
badgeValue={field.value}
direction="column"
/>
))}
</div>
);
}
export default LogHighlights;

View File

@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
import styles from './LogHighlights.module.scss';
interface TraceIdFieldProps {
traceId: string;
}
function TraceIdField({ traceId }: TraceIdFieldProps): JSX.Element {
return (
<Link
to={{ pathname: `/trace/${traceId}` }}
target="_blank"
rel="noreferrer"
className={styles.traceLink}
title={traceId}
>
{traceId}
</Link>
);
}
export default TraceIdField;

View File

@@ -0,0 +1,102 @@
import { ReactNode } from 'react';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { ILog } from 'types/api/logs/log';
import styles from './LogHighlights.module.scss';
import TraceIdField from './TraceIdField';
// Severity badge color mirrors the LogStateIndicator bar
const SEVERITY_COLOR: Record<string, BadgeColor> = {
[LogType.TRACE]: 'forest',
[LogType.DEBUG]: 'aqua',
[LogType.INFO]: 'robin',
[LogType.WARN]: 'amber',
[LogType.ERROR]: 'cherry',
[LogType.FATAL]: 'sakura',
};
export interface LogHighlightConfig {
key: string;
label: string;
render: (log: ILog) => ReactNode | null;
}
// Resource/attribute lookup (keys like `service.name` live in resources_string,
// occasionally attributes_string). Typed loosely as these are string maps.
const getAttr = (log: ILog, key: string): string =>
(log.resources_string as unknown as Record<string, string>)?.[key] ||
(log.attributes_string as unknown as Record<string, string>)?.[key] ||
'';
const valueBadge = (
value: string,
options?: { prefix?: ReactNode; color?: BadgeColor },
): ReactNode => (
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
{options?.prefix}
<span className={styles.badgeText} title={value}>
{value}
</span>
</Badge>
);
export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
{
key: 'service',
label: 'SERVICE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.name');
return value
? valueBadge(value, {
prefix: <span className={styles.serviceDot} />,
})
: null;
},
},
{
key: 'severity',
label: 'SEVERITY',
render: (log): ReactNode | null => {
if (!log.severity_text) {
return null;
}
return valueBadge(log.severity_text, {
color: SEVERITY_COLOR[getLogIndicatorType(log)] ?? 'vanilla',
});
},
},
{
key: 'namespace',
label: 'NAMESPACE',
render: (log): ReactNode | null => {
const value = getAttr(log, 'service.namespace');
return value ? valueBadge(value) : null;
},
},
{
key: 'environment',
label: 'ENVIRONMENT',
render: (log): ReactNode | null => {
const value = getAttr(log, 'deployment.environment');
return value ? valueBadge(value) : null;
},
},
{
key: 'traceId',
label: 'TRACE ID',
render: (log): ReactNode | null => {
const traceId = log.trace_id || log.traceId;
return traceId ? <TraceIdField traceId={traceId} /> : null;
},
},
{
key: 'spanId',
label: 'SPAN ID',
render: (log): ReactNode | null => {
const spanId = log.span_id || log.spanID;
return spanId ? valueBadge(spanId) : null;
},
},
];

View File

@@ -115,6 +115,45 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
});
it('renders Highlights for fields present on the log, omitting absent ones', () => {
const logWithMeta = {
...mockLog,
severity_text: 'ERROR',
trace_id: 'trace-abc',
resources_string: {
'service.name': 'checkout',
'deployment.environment': 'production',
},
} as unknown as ILog;
renderDrawer({ log: logWithMeta });
const highlights = screen.getByTestId('log-details-highlights');
expect(highlights).toHaveTextContent('SEVERITY');
expect(highlights).toHaveTextContent('ERROR');
expect(highlights).toHaveTextContent('SERVICE');
expect(highlights).toHaveTextContent('checkout');
expect(highlights).toHaveTextContent('ENVIRONMENT');
expect(highlights).toHaveTextContent('production');
expect(highlights).toHaveTextContent('TRACE ID');
// Absent fields are omitted (no namespace / span id on this log).
expect(highlights).not.toHaveTextContent('NAMESPACE');
expect(highlights).not.toHaveTextContent('SPAN ID');
});
it('links the trace id highlight to the trace detail in a new tab', () => {
const logWithTrace = {
...mockLog,
trace_id: 'trace-abc',
} as unknown as ILog;
renderDrawer({ log: logWithTrace });
const link = screen.getByRole('link', { name: 'trace-abc' });
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('href')).toContain('/trace/trace-abc');
});
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')];

View File

@@ -55,6 +55,7 @@ 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 LogHighlights from './LogHighlights/LogHighlights';
import './LogDetails.styles.scss';
@@ -399,6 +400,8 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"

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

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

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

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

@@ -11,10 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
HTTPHostPort = GetOrDefaultEnv("SIGNOZ_HTTP_HOST_PORT", "0.0.0.0:8080") // Address to serve http (query service)
PrivateHostPort = GetOrDefaultEnv("SIGNOZ_PRIVATE_HOST_PORT", "0.0.0.0:8085") // Address to server internal services like alert manager
OpAmpWsEndpoint = GetOrDefaultEnv("SIGNOZ_OPAMP_WS_ENDPOINT", "0.0.0.0:4320") // address for opamp websocket
const (
HTTPHostPort = "0.0.0.0:8080" // Address to serve http (query service)
PrivateHostPort = "0.0.0.0:8085" // Address to server internal services like alert manager
OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
)
const MaxAllowedPointsInTimeSeries = 300

View File

@@ -0,0 +1,78 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -0,0 +1,50 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}