mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-21 19:30:29 +01:00
Compare commits
6 Commits
base-path-
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42aa2b47e6 | ||
|
|
6bd35c4676 | ||
|
|
18f004586a | ||
|
|
44f30b8ae0 | ||
|
|
cbfbe1c670 | ||
|
|
7433e2d272 |
64
.github/CODEOWNERS
vendored
64
.github/CODEOWNERS
vendored
@@ -16,38 +16,38 @@ go.mod @therealpandey
|
||||
|
||||
# Scaffold Owners
|
||||
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
/pkg/config/ @vikrantgupta25
|
||||
/pkg/errors/ @vikrantgupta25
|
||||
/pkg/factory/ @vikrantgupta25
|
||||
/pkg/types/ @vikrantgupta25
|
||||
/pkg/valuer/ @vikrantgupta25
|
||||
/cmd/ @vikrantgupta25
|
||||
.golangci.yml @vikrantgupta25
|
||||
|
||||
# Zeus Owners
|
||||
|
||||
/pkg/zeus/ @therealpandey
|
||||
/ee/zeus/ @therealpandey
|
||||
/pkg/licensing/ @therealpandey
|
||||
/ee/licensing/ @therealpandey
|
||||
/pkg/zeus/ @vikrantgupta25
|
||||
/ee/zeus/ @vikrantgupta25
|
||||
/pkg/licensing/ @vikrantgupta25
|
||||
/ee/licensing/ @vikrantgupta25
|
||||
|
||||
# SQL Owners
|
||||
|
||||
/pkg/sqlmigration/ @therealpandey
|
||||
/ee/sqlmigration/ @therealpandey
|
||||
/pkg/sqlschema/ @therealpandey
|
||||
/ee/sqlschema/ @therealpandey
|
||||
/pkg/sqlmigration/ @vikrantgupta25
|
||||
/ee/sqlmigration/ @vikrantgupta25
|
||||
/pkg/sqlschema/ @vikrantgupta25
|
||||
/ee/sqlschema/ @vikrantgupta25
|
||||
|
||||
# Analytics Owners
|
||||
|
||||
/pkg/analytics/ @therealpandey
|
||||
/pkg/statsreporter/ @therealpandey
|
||||
/pkg/analytics/ @vikrantgupta25
|
||||
/pkg/statsreporter/ @vikrantgupta25
|
||||
|
||||
# Emailing Owners
|
||||
|
||||
/pkg/emailing/ @therealpandey
|
||||
/pkg/types/emailtypes/ @therealpandey
|
||||
/templates/email/ @therealpandey
|
||||
/pkg/emailing/ @vikrantgupta25
|
||||
/pkg/types/emailtypes/ @vikrantgupta25
|
||||
/templates/email/ @vikrantgupta25
|
||||
|
||||
# Querier Owners
|
||||
|
||||
@@ -97,23 +97,23 @@ go.mod @therealpandey
|
||||
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
/pkg/authz/ @therealpandey
|
||||
/ee/authz/ @therealpandey
|
||||
/pkg/authn/ @therealpandey
|
||||
/ee/authn/ @therealpandey
|
||||
/pkg/modules/user/ @therealpandey
|
||||
/pkg/modules/session/ @therealpandey
|
||||
/pkg/modules/organization/ @therealpandey
|
||||
/pkg/modules/authdomain/ @therealpandey
|
||||
/pkg/modules/role/ @therealpandey
|
||||
/pkg/authz/ @vikrantgupta25
|
||||
/ee/authz/ @vikrantgupta25
|
||||
/pkg/authn/ @vikrantgupta25
|
||||
/ee/authn/ @vikrantgupta25
|
||||
/pkg/modules/user/ @vikrantgupta25
|
||||
/pkg/modules/session/ @vikrantgupta25
|
||||
/pkg/modules/organization/ @vikrantgupta25
|
||||
/pkg/modules/authdomain/ @vikrantgupta25
|
||||
/pkg/modules/role/ @vikrantgupta25
|
||||
|
||||
# IdentN Owners
|
||||
/pkg/identn/ @therealpandey
|
||||
/pkg/http/middleware/identn.go @therealpandey
|
||||
/pkg/identn/ @vikrantgupta25
|
||||
/pkg/http/middleware/identn.go @vikrantgupta25
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @vikrantgupta25
|
||||
|
||||
# OpenAPI types generator
|
||||
|
||||
|
||||
@@ -20,4 +20,3 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Types
|
||||
|
||||
Domain types in `pkg/types/<domain>/` live on three serialization boundaries — inbound HTTP, outbound HTTP, and SQL — on top of an in-memory domain representation. SigNoz's convention is **core-type-first**: every domain defines a single canonical type `X`, and specialized flavors (`PostableX`, `GettableX`, `UpdatableX`, `StorableX`) are introduced **only when they actually differ from `X`**. This guide spells out when each flavor is warranted and how they relate to each other.
|
||||
|
||||
Before reading, make sure you have read [abstractions.md](abstractions.md) — the rules here build on its guidance that every new type must earn its place.
|
||||
|
||||
## The core type is required
|
||||
|
||||
Every domain package in `pkg/types/<domain>/` defines exactly one core type `X`: `AuthDomain`, `Channel`, `Rule`, `Dashboard`, `Role`, `PlannedMaintenance`. This is the canonical in-memory representation of the domain object. Domain methods, validation invariants, and business logic hang off `X` — not off the flavor types.
|
||||
|
||||
Two rules shape how the core type behaves:
|
||||
|
||||
- **Conversions can be either `New<Output>From<Input>` or a receiver-style `(x *X) ToY()` method.** Either form is fine; pick whichever reads best at the call site:
|
||||
|
||||
```go
|
||||
// Constructor form
|
||||
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
|
||||
|
||||
// Receiver form
|
||||
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
```
|
||||
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
|
||||
|
||||
Domain packages under `pkg/types/` must not import from other `pkg/` packages. Keep the core type's methods lightweight and push orchestration out to the module layer.
|
||||
|
||||
## Add a flavor only when it differs
|
||||
|
||||
For each of the four flavors, create it only if its shape diverges from `X`. If a flavor would have the same fields and tags as `X`, reuse `X` directly, or declare a type alias. Every flavor must earn its place per [abstractions.md](abstractions.md) rule 6 ("Wrappers must add semantics, not just rename").
|
||||
|
||||
| Flavor | Create it when it differs in… |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PostableX` | JSON shape differs from `X` — typically no `Id`, no audit fields, no server-computed fields. Often owns input validation via `Validate()` or a custom `UnmarshalJSON`. |
|
||||
| `GettableX` | Response shape adds server-computed fields that are not persisted — e.g., `GettableAuthDomain` adds `AuthNProviderInfo`, which is resolved at read time. |
|
||||
| `UpdatableX` | Only a strict subset of `PostableX` is replaceable on PUT. If the updatable shape equals `PostableX`, reuse `PostableX`. |
|
||||
| `StorableX` | DB row shape differs from `X` — usually `X` carries nested typed config while `StorableX` carries a flat `Data string` JSON column, plus bun tags, audit mixins, and an `OrgID`. If `X` already has those, skip the flavor. |
|
||||
|
||||
The failure mode this rule exists to prevent: minting all four flavors on reflex for every new resource, even when two or three are structurally identical. Each unnecessary flavor is another type contributors must understand and another conversion that can drift.
|
||||
|
||||
## Worked examples
|
||||
|
||||
### Channel — core type only
|
||||
|
||||
```go
|
||||
type Channels = []*Channel
|
||||
type GettableChannels = []*Channel
|
||||
|
||||
type Channel struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Name string `json:"name" required:"true" bun:"name"`
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
}
|
||||
```
|
||||
|
||||
`Channel` is both the domain type and the bun row. `GettableChannels` is a **type alias** because `*Channel` already serializes correctly as a response. There is no `StorableChannel`, `PostableChannel`, or `UpdatableChannel` — those would be identical to `Channel` and so do not exist. Prefer this shape when it works.
|
||||
|
||||
### AuthDomain — all four flavors
|
||||
|
||||
```go
|
||||
type AuthDomain struct {
|
||||
storableAuthDomain *StorableAuthDomain
|
||||
authDomainConfig *AuthDomainConfig
|
||||
}
|
||||
|
||||
type StorableAuthDomain struct {
|
||||
bun.BaseModel `bun:"table:auth_domain"`
|
||||
types.Identifiable
|
||||
Name string `bun:"name"`
|
||||
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
|
||||
OrgID valuer.UUID `bun:"org_id"`
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
type PostableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"` // Name intentionally absent
|
||||
}
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
*StorableAuthDomain
|
||||
*AuthDomainConfig
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
```
|
||||
|
||||
Each flavor exists for a concrete reason:
|
||||
|
||||
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
|
||||
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
|
||||
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
|
||||
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
- **Validation belongs on the core type `X`.** Putting it on `X` means every write path — HTTP create, HTTP update, in-process migration, replay — runs the same checks. `Validate()` on `PostableX` is reserved for checks that are specific to the request shape and do not apply to `X`. `UnmarshalJSON` on `PostableX` is a separate tool that lives there because decoding only happens at the HTTP boundary — `PostableAuthDomain.UnmarshalJSON` rejecting a malformed domain name at decode time is the canonical example.
|
||||
|
||||
```go
|
||||
// Domain invariants: every write path re-runs these.
|
||||
func (x *X) Validate() error { ... }
|
||||
|
||||
// Request-shape-only: checks that do not apply once the value is persisted.
|
||||
func (p *PostableX) Validate() error { ... }
|
||||
```
|
||||
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
|
||||
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.
|
||||
|
||||
## A note on `UpdatableX` and `PatchableX`
|
||||
|
||||
- `UpdatableX` — the body for PUT (full replace) when the shape is a strict subset of `PostableX`. If the updatable shape equals `PostableX`, reuse `PostableX`.
|
||||
- `PatchableX` — the body for PATCH (partial update); only the fields a client is allowed to patch. For example, `PatchableRole` carries a single `Description` field even though `Role` has many — clients may patch the description but not anything else.
|
||||
|
||||
```go
|
||||
type PatchableRole struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
Both are optional. Do not introduce them if `PostableX` already covers the case.
|
||||
|
||||
## What to avoid
|
||||
|
||||
- **Do not mint a flavor that mirrors the core type.** If `StorableX` would have the same fields as `X`, use `X` directly with `bun.BaseModel` embedded. `Channel` is the canonical example.
|
||||
- **Do not bolt domain methods onto `StorableX`.** Storage types are data carriers. Domain methods live on `X`.
|
||||
- **Do not invent new suffixes** (`Creatable`, `Fetchable`, `Savable`). The core type plus `Postable` / `Gettable` / `Updatable` / `Patchable` / `Storable` covers every case that exists today.
|
||||
- **Spelling — `Updatable`, not `Updateable`.** `Updateable` is a common typo. Prefer the shorter form when introducing new types, and rename any stragglers you come across.
|
||||
- **Spelling — `Storable`, not `Storeable`.** `Storeable` is a common typo. Prefer the shorter form when introducing new types, and rename any stragglers you come across.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
- `pkg/types/<domain>/` must not import from other `pkg/` packages.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [abstractions.md](abstractions.md) — when to introduce a new type at all.
|
||||
- [handler.md](handler.md) — struct tag rules at the HTTP boundary.
|
||||
- [packages.md](packages.md) — where types live under `pkg/types/`.
|
||||
- [sql.md](sql.md) — star-schema requirements for `StorableX`.
|
||||
@@ -19,11 +19,11 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.AWS.DeploymentRegion)
|
||||
q.Set("region", req.Config.Aws.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
@@ -39,7 +39,9 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
|
||||
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
|
||||
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -122,6 +124,9 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
AWS: cloudintegrationtypes.NewAWSIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
|
||||
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
|
||||
EnabledRegions: account.Config.AWS.Regions,
|
||||
TelemetryCollectionStrategy: collectionStrategy,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
}
|
||||
|
||||
// start the usagemanager
|
||||
usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter, signoz.Flagger)
|
||||
usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/encryption"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
|
||||
@@ -45,18 +43,15 @@ type Manager struct {
|
||||
zeus zeus.Zeus
|
||||
|
||||
orgGetter organization.Getter
|
||||
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, orgGetter organization.Getter, flagger flagger.Flagger) (*Manager, error) {
|
||||
func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, orgGetter organization.Getter) (*Manager, error) {
|
||||
m := &Manager{
|
||||
clickhouseConn: clickhouseConn,
|
||||
licenseService: licenseService,
|
||||
scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC
|
||||
zeus: zeus,
|
||||
orgGetter: orgGetter,
|
||||
flagger: flagger,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -173,14 +168,7 @@ func (lm *Manager) UploadUsage(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(organization.ID)
|
||||
useZeus := lm.flagger.BooleanOrEmpty(ctx, flagger.FeaturePutMetersInZeus, evalCtx)
|
||||
|
||||
if useZeus {
|
||||
errv2 = lm.zeus.PutMetersV2(ctx, payload.LicenseKey.String(), body)
|
||||
} else {
|
||||
errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body)
|
||||
}
|
||||
errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body)
|
||||
if errv2 != nil {
|
||||
slog.ErrorContext(ctx, "failed to upload usage", errors.Attr(errv2))
|
||||
// not returning error here since it is captured in the failed count
|
||||
|
||||
@@ -136,18 +136,6 @@ func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *Provider) PutMetersV2(ctx context.Context, key string, data []byte) error {
|
||||
_, err := provider.do(
|
||||
ctx,
|
||||
provider.config.URL.JoinPath("/v1/meters"),
|
||||
http.MethodPost,
|
||||
key,
|
||||
data,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
|
||||
body, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
|
||||
@@ -66,8 +66,6 @@ module.exports = {
|
||||
rules: {
|
||||
// Asset migration — base-path safety
|
||||
'rulesdir/no-unsupported-asset-pattern': 'error',
|
||||
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
|
||||
'rulesdir/no-raw-absolute-path': 'warn',
|
||||
|
||||
// Code quality rules
|
||||
'prefer-const': 'error', // Enforces const for variables never reassigned
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ESLint rule: no-raw-absolute-path
|
||||
*
|
||||
* Catches patterns that break at runtime when the app is served from a
|
||||
* sub-path (e.g. /signoz/):
|
||||
*
|
||||
* 1. window.open(path, '_blank')
|
||||
* → use openInNewTab(path) which calls withBasePath internally
|
||||
*
|
||||
* 2. window.location.origin + path / `${window.location.origin}${path}`
|
||||
* → use getAbsoluteUrl(path)
|
||||
*
|
||||
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
|
||||
* → use getBaseUrl() to include the base path
|
||||
*
|
||||
* 4. window.location.href = path
|
||||
* → use withBasePath(path) or navigate() for internal navigation
|
||||
*
|
||||
* External URLs (first arg starts with "http") are explicitly allowed.
|
||||
*/
|
||||
|
||||
function isOriginAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'origin' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isHrefAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'href' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalUrl(node) {
|
||||
if (node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value.startsWith('http://') || node.value.startsWith('https://');
|
||||
}
|
||||
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
|
||||
const raw = node.quasis[0].value.raw;
|
||||
return raw.startsWith('http://') || raw.startsWith('https://');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
|
||||
function isSafeHelperCall(node) {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
|
||||
category: 'Base Path Safety',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
windowOpen:
|
||||
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
|
||||
originConcat:
|
||||
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
|
||||
originDirect:
|
||||
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
|
||||
hrefAssign:
|
||||
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
// window.open(path, ...) — allow only external first-arg URLs
|
||||
CallExpression(node) {
|
||||
const { callee, arguments: args } = node;
|
||||
if (
|
||||
callee.type !== 'MemberExpression' ||
|
||||
callee.object.type !== 'Identifier' ||
|
||||
callee.object.name !== 'window' ||
|
||||
callee.property.name !== 'open'
|
||||
)
|
||||
return;
|
||||
if (args.length < 1) return;
|
||||
if (isExternalUrl(args[0])) return;
|
||||
if (isSafeHelperCall(args[0])) return;
|
||||
|
||||
context.report({ node, messageId: 'windowOpen' });
|
||||
},
|
||||
|
||||
// window.location.origin + path
|
||||
BinaryExpression(node) {
|
||||
if (node.operator !== '+') return;
|
||||
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// `${window.location.origin}${path}`
|
||||
TemplateLiteral(node) {
|
||||
if (node.expressions.some(isOriginAccess)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// window.location.origin used directly (not in concatenation)
|
||||
// Catches: frontendBaseUrl: window.location.origin
|
||||
MemberExpression(node) {
|
||||
if (!isOriginAccess(node)) return;
|
||||
|
||||
const parent = node.parent;
|
||||
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
|
||||
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
|
||||
if (parent.type === 'TemplateLiteral') return;
|
||||
|
||||
context.report({ node, messageId: 'originDirect' });
|
||||
},
|
||||
|
||||
// window.location.href = path
|
||||
AssignmentExpression(node) {
|
||||
if (node.operator !== '=') return;
|
||||
if (!isHrefAccess(node.left)) return;
|
||||
|
||||
// Allow external URLs
|
||||
if (isExternalUrl(node.right)) return;
|
||||
// Allow safe helper calls
|
||||
if (isSafeHelperCall(node.right)) return;
|
||||
|
||||
context.report({ node, messageId: 'hrefAssign' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="[[.BaseHref]]" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-cache, no-store, must-revalidate, max-age: 0"
|
||||
@@ -60,7 +59,7 @@
|
||||
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
|
||||
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body data-theme="default">
|
||||
<script>
|
||||
@@ -137,7 +136,7 @@
|
||||
})(document, 'script');
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -62,8 +62,7 @@
|
||||
"@signozhq/popover": "0.1.2",
|
||||
"@signozhq/radio-group": "0.0.4",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/tabs": "0.0.11",
|
||||
"@signozhq/table": "0.3.8",
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "0.0.3",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
|
||||
@@ -244,18 +244,12 @@ export const ShortcutsPage = Loadable(
|
||||
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const Integrations = Loadable(
|
||||
export const InstalledIntegrations = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
|
||||
),
|
||||
);
|
||||
export const IntegrationsDetailsPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const MessagingQueuesMainPage = Loadable(
|
||||
() =>
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
ForgotPassword,
|
||||
Home,
|
||||
InfrastructureMonitoring,
|
||||
Integrations,
|
||||
IntegrationsDetailsPage,
|
||||
InstalledIntegrations,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LiveLogs,
|
||||
@@ -390,17 +389,10 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'WORKSPACE_ACCESS_RESTRICTED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS_DETAIL,
|
||||
exact: true,
|
||||
component: IntegrationsDetailsPage,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS,
|
||||
exact: true,
|
||||
component: Integrations,
|
||||
component: InstalledIntegrations,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS',
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { initReactI18next } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
import cacheBursting from '../../i18n-translations-hash.json';
|
||||
|
||||
@@ -25,7 +24,7 @@ i18n
|
||||
const ns = namespace[0];
|
||||
const pathkey = `/${language}/${ns}`;
|
||||
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
|
||||
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
|
||||
return `/locales/${language}/${namespace}.json?h=${hash}`;
|
||||
},
|
||||
},
|
||||
react: {
|
||||
|
||||
19
frontend/src/api/Integrations/removeAwsIntegrationAccount.ts
Normal file
19
frontend/src/api/Integrations/removeAwsIntegrationAccount.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
const removeAwsIntegrationAccount = async (
|
||||
accountId: string,
|
||||
): Promise<SuccessResponse<Record<string, never>> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`/cloud-integrations/aws/accounts/${accountId}/disconnect`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default removeAwsIntegrationAccount;
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
@@ -18,7 +17,6 @@ export const GeneratedAPIInstance = <T>(
|
||||
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
|
||||
};
|
||||
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
|
||||
@@ -11,7 +11,6 @@ import axios, {
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
@@ -68,39 +67,6 @@ export const interceptorsRequestResponse = (
|
||||
return value;
|
||||
};
|
||||
|
||||
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
|
||||
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
|
||||
function prependBase(base: string, path: string): string {
|
||||
return path.startsWith(base) ? path : base + path.slice(1);
|
||||
}
|
||||
|
||||
// Prepends the runtime base path to outgoing requests so API calls work under
|
||||
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
|
||||
// (dev baseURL is a full http:// URL, not an absolute path).
|
||||
export const interceptorsRequestBasePath = (
|
||||
value: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig => {
|
||||
const basePath = getBasePath();
|
||||
if (basePath === '/') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.baseURL?.startsWith('/')) {
|
||||
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
|
||||
value.baseURL = prependBase(basePath, value.baseURL);
|
||||
} else if (value.baseURL?.startsWith('http')) {
|
||||
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
|
||||
const url = new URL(value.baseURL);
|
||||
url.pathname = prependBase(basePath, url.pathname);
|
||||
value.baseURL = url.toString();
|
||||
} else if (!value.baseURL && value.url?.startsWith('/')) {
|
||||
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
|
||||
value.url = prependBase(basePath, value.url);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const interceptorRejected = async (
|
||||
value: AxiosResponse<any>,
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
@@ -167,7 +133,6 @@ const instance = axios.create({
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
|
||||
|
||||
export const AxiosAlertManagerInstance = axios.create({
|
||||
@@ -182,7 +147,6 @@ ApiV2Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
// axios V3
|
||||
export const ApiV3Instance = axios.create({
|
||||
@@ -194,7 +158,6 @@ ApiV3Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V4
|
||||
@@ -207,7 +170,6 @@ ApiV4Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V5
|
||||
@@ -220,7 +182,6 @@ ApiV5Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
@@ -233,7 +194,6 @@ LogEventAxiosInstance.interceptors.response.use(
|
||||
interceptorRejectedBase,
|
||||
);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
AxiosAlertManagerInstance.interceptors.response.use(
|
||||
@@ -241,7 +201,6 @@ AxiosAlertManagerInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
export { apiV1 };
|
||||
export default instance;
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
export const getHostAttributeKeys = async (
|
||||
searchText = '',
|
||||
entity: InfraMonitoringEntity,
|
||||
entity: K8sCategory,
|
||||
): Promise<SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const response: AxiosResponse<{
|
||||
|
||||
@@ -33,8 +33,6 @@ export interface HostData {
|
||||
hostName: string;
|
||||
active: boolean;
|
||||
os: string;
|
||||
/** Present when the list API returns grouped rows or extra resource attributes. */
|
||||
meta?: Record<string, string>;
|
||||
cpu: number;
|
||||
cpuTimeSeries: TimeSeries;
|
||||
memory: number;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNodesListPayload {
|
||||
export interface K8sClustersListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
@@ -19,24 +18,23 @@ export interface K8sNodesListPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodeData {
|
||||
nodeUID: string;
|
||||
nodeCPUUsage: number;
|
||||
nodeCPUAllocatable: number;
|
||||
nodeMemoryUsage: number;
|
||||
nodeMemoryAllocatable: number;
|
||||
export interface K8sClustersData {
|
||||
clusterUID: string;
|
||||
cpuUsage: number;
|
||||
cpuAllocatable: number;
|
||||
memoryUsage: number;
|
||||
memoryAllocatable: number;
|
||||
meta: {
|
||||
k8s_node_name: string;
|
||||
k8s_node_uid: string;
|
||||
k8s_cluster_name: string;
|
||||
k8s_cluster_uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesListResponse {
|
||||
export interface K8sClustersListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: K8sNodeData[];
|
||||
records: K8sClustersData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
@@ -44,32 +42,30 @@ export interface K8sNodesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const nodesMetaMap = [
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
|
||||
export const clustersMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
|
||||
] as const;
|
||||
|
||||
export function mapNodesMeta(
|
||||
export function mapClustersMeta(
|
||||
raw: Record<string, unknown>,
|
||||
): K8sNodeData['meta'] {
|
||||
): K8sClustersData['meta'] {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
nodesMetaMap.forEach(({ dot, under }) => {
|
||||
clustersMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sNodeData['meta'];
|
||||
return out as K8sClustersData['meta'];
|
||||
}
|
||||
|
||||
export const getK8sNodesList = async (
|
||||
props: K8sBaseFilters,
|
||||
export const getK8sClustersList = async (
|
||||
props: K8sClustersListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
|
||||
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
@@ -104,16 +100,16 @@ export const getK8sNodesList = async (
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/nodes/list', requestProps, {
|
||||
const response = await axios.post('/clusters/list', requestProps, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
const payload: K8sNodesListResponse = response.data;
|
||||
const payload: K8sClustersListResponse = response.data;
|
||||
|
||||
// one-liner to map dot→underscore
|
||||
// one-liner meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapNodesMeta(record.meta as Record<string, unknown>),
|
||||
meta: mapClustersMeta(record.meta as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sDaemonSetsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sDaemonSetsData {
|
||||
daemonSetName: string;
|
||||
@@ -36,7 +48,6 @@ export interface K8sDaemonSetsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const daemonSetsMetaMap = [
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
@@ -57,43 +68,45 @@ export function mapDaemonSetsMeta(
|
||||
}
|
||||
|
||||
export const getK8sDaemonSetsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sDaemonSetsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
// filter prep (unchanged)…
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/daemonsets/list', requestProps, {
|
||||
signal,
|
||||
@@ -101,6 +114,7 @@ export const getK8sDaemonSetsList = async (
|
||||
});
|
||||
const payload: K8sDaemonSetsListResponse = response.data;
|
||||
|
||||
// single-line meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sDeploymentsListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -49,7 +48,6 @@ export interface K8sDeploymentsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const deploymentsMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
@@ -70,43 +68,44 @@ export function mapDeploymentsMeta(
|
||||
}
|
||||
|
||||
export const getK8sDeploymentsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sDeploymentsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sDeploymentsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/deployments/list', requestProps, {
|
||||
signal,
|
||||
@@ -114,6 +113,7 @@ export const getK8sDeploymentsList = async (
|
||||
});
|
||||
const payload: K8sDeploymentsListResponse = response.data;
|
||||
|
||||
// single-line mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapDeploymentsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sJobsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sJobsData {
|
||||
jobName: string;
|
||||
@@ -38,7 +50,6 @@ export interface K8sJobsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const jobsMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
@@ -57,43 +68,44 @@ export function mapJobsMeta(raw: Record<string, unknown>): K8sJobsData['meta'] {
|
||||
}
|
||||
|
||||
export const getK8sJobsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sJobsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sJobsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/jobs/list', requestProps, {
|
||||
signal,
|
||||
@@ -101,6 +113,7 @@ export const getK8sJobsList = async (
|
||||
});
|
||||
const payload: K8sJobsListResponse = response.data;
|
||||
|
||||
// one-liner meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapJobsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNamespacesListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -41,7 +40,6 @@ export interface K8sNamespacesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const namespacesMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
@@ -61,43 +59,44 @@ export function mapNamespacesMeta(
|
||||
}
|
||||
|
||||
export const getK8sNamespacesList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sNamespacesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNamespacesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/namespaces/list', requestProps, {
|
||||
signal,
|
||||
@@ -0,0 +1,127 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNodesListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesData {
|
||||
nodeUID: string;
|
||||
nodeCPUUsage: number;
|
||||
nodeCPUAllocatable: number;
|
||||
nodeMemoryUsage: number;
|
||||
nodeMemoryAllocatable: number;
|
||||
meta: {
|
||||
k8s_node_name: string;
|
||||
k8s_node_uid: string;
|
||||
k8s_cluster_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: K8sNodesData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingK8SAgentMetrics: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const nodesMetaMap = [
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapNodesMeta(
|
||||
raw: Record<string, unknown>,
|
||||
): K8sNodesData['meta'] {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
nodesMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sNodesData['meta'];
|
||||
}
|
||||
|
||||
export const getK8sNodesList = async (
|
||||
props: K8sNodesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/nodes/list', requestProps, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
const payload: K8sNodesListResponse = response.data;
|
||||
|
||||
// one-liner to map dot→underscore
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapNodesMeta(record.meta as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload,
|
||||
params: requestProps,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const podsMetaMap = [
|
||||
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
|
||||
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
|
||||
// clone everything
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
// overlay only the dot→under mappings
|
||||
podsMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sPodsData['meta'];
|
||||
export interface K8sPodsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
@@ -86,44 +71,72 @@ export interface K8sPodsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export const podsMetaMap = [
|
||||
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
|
||||
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
|
||||
// clone everything
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
// overlay only the dot→under mappings
|
||||
podsMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sPodsData['meta'];
|
||||
}
|
||||
|
||||
// getK8sPodsList
|
||||
export const getK8sPodsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sPodsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sPodsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/pods/list', requestProps, {
|
||||
signal,
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sVolumesListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sVolumesData {
|
||||
persistentVolumeClaimName: string;
|
||||
@@ -37,7 +49,6 @@ export interface K8sVolumesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const volumesMetaMap: Array<{
|
||||
dot: keyof Record<string, unknown>;
|
||||
under: keyof K8sVolumesData['meta'];
|
||||
@@ -57,8 +68,10 @@ export const volumesMetaMap: Array<{
|
||||
export function mapVolumesMeta(
|
||||
rawMeta: Record<string, unknown>,
|
||||
): K8sVolumesData['meta'] {
|
||||
// start with everything that was already there
|
||||
const out: Record<string, unknown> = { ...rawMeta };
|
||||
|
||||
// for each dot→under rule, if the raw has the dot, overwrite the underscore
|
||||
volumesMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in rawMeta) {
|
||||
const val = rawMeta[dot];
|
||||
@@ -70,47 +83,42 @@ export function mapVolumesMeta(
|
||||
}
|
||||
|
||||
export const getK8sVolumesList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sVolumesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sVolumesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const { orderBy, ...rest } = props;
|
||||
const basePayload = {
|
||||
...rest,
|
||||
filters: props.filters ?? { items: [], op: 'and' },
|
||||
...(orderBy != null ? { orderBy } : {}),
|
||||
};
|
||||
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...basePayload,
|
||||
filters: {
|
||||
...basePayload.filters,
|
||||
items: basePayload.filters.items.reduce<typeof basePayload.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
// Prepare filters
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof basePayload.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: basePayload;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/pvcs/list', requestProps, {
|
||||
signal,
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sStatefulSetsListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -48,7 +47,6 @@ export interface K8sStatefulSetsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const statefulSetsMetaMap = [
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
@@ -68,37 +66,42 @@ export function mapStatefulSetsMeta(
|
||||
}
|
||||
|
||||
export const getK8sStatefulSetsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sStatefulSetsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sStatefulSetsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<TagFilter['items']>((acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as TagFilter['items']),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
// Prepare filters
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/statefulsets/list', requestProps, {
|
||||
signal,
|
||||
@@ -106,6 +109,7 @@ export const getK8sStatefulSetsList = async (
|
||||
});
|
||||
const payload: K8sStatefulSetsListResponse = response.data;
|
||||
|
||||
// apply our helper
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapStatefulSetsMeta(record.meta as Record<string, unknown>),
|
||||
88
frontend/src/api/integration/aws/index.ts
Normal file
88
frontend/src/api/integration/aws/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from 'api';
|
||||
import {
|
||||
CloudAccount,
|
||||
Service,
|
||||
ServiceData,
|
||||
UpdateServiceConfigPayload,
|
||||
UpdateServiceConfigResponse,
|
||||
} from 'container/CloudIntegrationPage/ServicesSection/types';
|
||||
import {
|
||||
AccountConfigPayload,
|
||||
AccountConfigResponse,
|
||||
ConnectionParams,
|
||||
ConnectionUrlResponse,
|
||||
} from 'types/api/integrations/aws';
|
||||
|
||||
export const getAwsAccounts = async (): Promise<CloudAccount[]> => {
|
||||
const response = await axios.get('/cloud-integrations/aws/accounts');
|
||||
|
||||
return response.data.data.accounts;
|
||||
};
|
||||
|
||||
export const getAwsServices = async (
|
||||
cloudAccountId?: string,
|
||||
): Promise<Service[]> => {
|
||||
const params = cloudAccountId
|
||||
? { cloud_account_id: cloudAccountId }
|
||||
: undefined;
|
||||
const response = await axios.get('/cloud-integrations/aws/services', {
|
||||
params,
|
||||
});
|
||||
|
||||
return response.data.data.services;
|
||||
};
|
||||
|
||||
export const getServiceDetails = async (
|
||||
serviceId: string,
|
||||
cloudAccountId?: string,
|
||||
): Promise<ServiceData> => {
|
||||
const params = cloudAccountId
|
||||
? { cloud_account_id: cloudAccountId }
|
||||
: undefined;
|
||||
const response = await axios.get(
|
||||
`/cloud-integrations/aws/services/${serviceId}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const generateConnectionUrl = async (params: {
|
||||
agent_config: { region: string };
|
||||
account_config: { regions: string[] };
|
||||
account_id?: string;
|
||||
}): Promise<ConnectionUrlResponse> => {
|
||||
const response = await axios.post(
|
||||
'/cloud-integrations/aws/accounts/generate-connection-url',
|
||||
params,
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateAccountConfig = async (
|
||||
accountId: string,
|
||||
payload: AccountConfigPayload,
|
||||
): Promise<AccountConfigResponse> => {
|
||||
const response = await axios.post<AccountConfigResponse>(
|
||||
`/cloud-integrations/aws/accounts/${accountId}/config`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateServiceConfig = async (
|
||||
serviceId: string,
|
||||
payload: UpdateServiceConfigPayload,
|
||||
): Promise<UpdateServiceConfigResponse> => {
|
||||
const response = await axios.post<UpdateServiceConfigResponse>(
|
||||
`/cloud-integrations/aws/services/${serviceId}/config`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConnectionParams = async (): Promise<ConnectionParams> => {
|
||||
const response = await axios.get(
|
||||
'/cloud-integrations/aws/accounts/generate-connection-params',
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="dotted-double-line-pattern" x="0" y="0" width="6" height="8" patternUnits="userSpaceOnUse">
|
||||
<rect width="2" height="2" rx="1" fill="#242834" />
|
||||
<rect y="6" width="2" height="2" rx="1" fill="#242834" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="929" height="8" fill="url(#dotted-double-line-pattern)" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 442 B |
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -24,7 +24,6 @@ import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/radio-group';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/tabs';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -12,8 +12,6 @@ import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
export interface NavigateToExplorerProps {
|
||||
filters: TagFilterItem[];
|
||||
@@ -135,11 +133,7 @@ export function useNavigateToExplorer(): (
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
if (sameTab) {
|
||||
window.location.href = withBasePath(newExplorerPath);
|
||||
} else {
|
||||
openInNewTab(newExplorerPath);
|
||||
}
|
||||
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
|
||||
},
|
||||
[
|
||||
prepareQuery,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.cloud-service-data-collected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.cloud-service-data-collected-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.cloud-service-data-collected-table-heading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-logs {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l3-background);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
|
||||
@@ -13,7 +13,6 @@ import GetMinMax from 'lib/getMinMax';
|
||||
import { Check, Info, Link2 } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
const routesToBeSharedWithTime = [
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
@@ -81,13 +80,17 @@ function ShareURLModal(): JSX.Element {
|
||||
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
|
||||
urlQuery.set(QueryParams.relativeTime, selectedTime);
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { toast } from '@signozhq/ui';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
@@ -31,7 +31,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/Integrations/utils', () => ({
|
||||
jest.mock('pages/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space, Typography } from 'antd';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
|
||||
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import './Containers.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityContainers(): JSX.Element {
|
||||
function Containers(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
@@ -43,4 +44,4 @@ function EntityContainers(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityContainers;
|
||||
export default Containers;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HostData } from 'api/infraMonitoring/getHostLists';
|
||||
|
||||
export type HostDetailProps = {
|
||||
host: HostData | null;
|
||||
isModalTimeSelection: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
.host-metric-traces {
|
||||
margin-top: 1rem;
|
||||
|
||||
.host-metric-traces-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.filter-section {
|
||||
flex: 1;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background-color: var(--l3-background) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-tag .ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metric-traces-table {
|
||||
.ant-table-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
|
||||
border-bottom: none;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th:has(.hostname-column-header) {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l1-foreground);
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
|
||||
}
|
||||
|
||||
.ant-table-cell:has(.hostname-column-value) {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.hostname-column-value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
.active-tag {
|
||||
color: var(--bg-forest-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
.ant-progress-bg {
|
||||
height: 8px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.ant-table-cell:first-child {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(2) {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(n + 3) {
|
||||
padding-right: 24px;
|
||||
}
|
||||
.column-header-right {
|
||||
text-align: right;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-thead
|
||||
> tr
|
||||
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ant-empty-normal {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-container::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import { ErrorText } from 'container/TimeSeriesView/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEWS } from '../constants';
|
||||
import { getHostTracesQueryPayload, selectedColumns } from './constants';
|
||||
import { getListColumns } from './utils';
|
||||
|
||||
import './HostMetricTraces.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleChangeTracesFilters: (
|
||||
value: IBuilderQuery['filters'],
|
||||
view: VIEWS,
|
||||
) => void;
|
||||
tracesFilters: IBuilderQuery['filters'];
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricTraces({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
handleChangeTracesFilters,
|
||||
tracesFilters,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const [traces, setTraces] = useState<any[]>([]);
|
||||
const [offset] = useState<number>(0);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items:
|
||||
tracesFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, tracesFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const queryPayload = useMemo(
|
||||
() =>
|
||||
getHostTracesQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
paginationQueryData?.offset || offset,
|
||||
tracesFilters,
|
||||
),
|
||||
[
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
offset,
|
||||
tracesFilters,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: [
|
||||
'hostMetricTraces',
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
offset,
|
||||
tracesFilters,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
paginationQueryData,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!queryPayload,
|
||||
});
|
||||
|
||||
const traceListColumns = getListColumns(selectedColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.newResult?.data?.result) {
|
||||
const currentData = data.payload.data.newResult.data.result;
|
||||
if (currentData.length > 0 && currentData[0].list) {
|
||||
if (offset === 0) {
|
||||
setTraces(currentData[0].list ?? []);
|
||||
} else {
|
||||
setTraces((prev) => [...prev, ...(currentData[0].list ?? [])]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data, offset]);
|
||||
|
||||
const isDataEmpty =
|
||||
!isLoading && !isFetching && !isError && traces.length === 0;
|
||||
const hasAdditionalFilters =
|
||||
tracesFilters?.items && tracesFilters?.items?.length > 1;
|
||||
|
||||
const totalCount =
|
||||
data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0;
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="host-metric-traces">
|
||||
<div className="host-metric-traces-header">
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void =>
|
||||
handleChangeTracesFilters(value, VIEWS.TRACES)
|
||||
}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && <ErrorText>{data?.error || 'Something went wrong'}</ErrorText>}
|
||||
|
||||
{isLoading && traces.length === 0 && <TracesLoading />}
|
||||
|
||||
{isDataEmpty && !hasAdditionalFilters && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataEmpty && hasAdditionalFilters && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && traces.length > 0 && (
|
||||
<div className="host-metric-traces-table">
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching && traces.length === 0}
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
loading={isFetching && traces.length === 0}
|
||||
dataSource={traces}
|
||||
columns={traceListColumns}
|
||||
onRow={(): Record<string, unknown> => ({
|
||||
onClick: (): void => handleRowClick(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricTraces;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
title: 'Timestamp',
|
||||
width: 200,
|
||||
render: (timestamp: string): string => new Date(timestamp).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: 'Service Name',
|
||||
dataIndex: ['data', 'serviceName'],
|
||||
key: 'serviceName-string-tag',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: ['data', 'name'],
|
||||
key: 'name-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: ['data', 'durationNano'],
|
||||
key: 'durationNano-float64-tag',
|
||||
width: 145,
|
||||
render: (duration: number): string => `${nanoToMilli(duration)}ms`,
|
||||
},
|
||||
{
|
||||
title: 'HTTP Method',
|
||||
dataIndex: ['data', 'httpMethod'],
|
||||
key: 'httpMethod-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
{
|
||||
title: 'Status Code',
|
||||
dataIndex: ['data', 'responseStatusCode'],
|
||||
key: 'responseStatusCode-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
];
|
||||
|
||||
export const selectedColumns: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: 'timestamp',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'serviceName',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'durationNano',
|
||||
dataType: DataTypes.Float64,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'httpMethod',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'responseStatusCode',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
export const getHostTracesQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
offset = 0,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps => ({
|
||||
query: {
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.EMPTY,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start,
|
||||
end,
|
||||
params: {
|
||||
dataSource: DataSource.TRACES,
|
||||
},
|
||||
tableParams: {
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'serviceName',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'serviceName--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'durationNano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
id: 'durationNano--float64--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'httpMethod',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'httpMethod--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'responseStatusCode',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'responseStatusCode--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const keyToLabelMap: Record<string, string> = {
|
||||
responseStatusCode: 'Status Code',
|
||||
};
|
||||
|
||||
export const getTraceListColumns = (
|
||||
export const getListColumns = (
|
||||
selectedColumns: BaseAutocompleteData[],
|
||||
): ColumnsType<RowData> => {
|
||||
const columns: ColumnsType<RowData> =
|
||||
@@ -0,0 +1,176 @@
|
||||
.host-detail-drawer {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 8px 16px;
|
||||
border-bottom: none;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: var(--padding-1);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.host-detail-drawer__host {
|
||||
.host-details-grid {
|
||||
.labels-row,
|
||||
.values-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 1.5fr 1.5fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.labels-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.host-details-metadata-label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
margin: 0;
|
||||
|
||||
&.active {
|
||||
color: var(--success-500);
|
||||
background: var(--success-100);
|
||||
border-color: var(--success-500);
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
color: var(--error-500);
|
||||
background: var(--error-100);
|
||||
border-color: var(--error-500);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 158px;
|
||||
.ant-progress {
|
||||
margin: 0;
|
||||
|
||||
.ant-progress-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
&.ant-card-bordered {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-and-search {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.views-tabs {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--l1-border);
|
||||
width: 114px;
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.selected_view::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.compass-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
595
frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx
Normal file
595
frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Progress,
|
||||
Radio,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
BarChart2,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
Package2,
|
||||
ScrollText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { VIEW_TYPES, VIEWS } from './constants';
|
||||
import Containers from './Containers/Containers';
|
||||
import { HostDetailProps } from './HostMetricDetail.interfaces';
|
||||
import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
|
||||
import HostMetricTraces from './HostMetricTraces/HostMetricTraces';
|
||||
import Metrics from './Metrics/Metrics';
|
||||
import Processes from './Processes/Processes';
|
||||
|
||||
import './HostMetricsDetail.styles.scss';
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function HostMetricsDetails({
|
||||
host,
|
||||
onClose,
|
||||
isModalTimeSelection,
|
||||
}: HostDetailProps): JSX.Element {
|
||||
const { maxTime, minTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
|
||||
minTime,
|
||||
]);
|
||||
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
endTime: endMs,
|
||||
}));
|
||||
|
||||
const lastSelectedInterval = useRef<Time | null>(null);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<Time>(
|
||||
lastSelectedInterval.current
|
||||
? lastSelectedInterval.current
|
||||
: (selectedTime as Time),
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
(searchParams.get('view') as VIEWS) || VIEWS.METRICS,
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const initialFilters = useMemo(() => {
|
||||
const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW);
|
||||
const queryKey =
|
||||
urlView === VIEW_TYPES.LOGS
|
||||
? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS
|
||||
: INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS;
|
||||
const filters = getFiltersFromParams(searchParams, queryKey);
|
||||
if (filters) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key: 'host.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: 'host.name--string--resource--false',
|
||||
},
|
||||
op: '=',
|
||||
value: host?.hostName || '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [host?.hostName, searchParams]);
|
||||
|
||||
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
|
||||
initialFilters,
|
||||
);
|
||||
|
||||
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
|
||||
initialFilters,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogFilters(initialFilters);
|
||||
setTracesFilters(initialFilters);
|
||||
}, [initialFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
|
||||
setSelectedInterval(currentSelectedInterval as Time);
|
||||
|
||||
if (currentSelectedInterval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
}, [selectedTime, minTime, maxTime]);
|
||||
|
||||
const handleTabChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
if (host?.hostName) {
|
||||
setSelectedView(e.target.value);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null),
|
||||
});
|
||||
}
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
lastSelectedInterval.current = interval as Time;
|
||||
setSelectedInterval(interval as Time);
|
||||
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
interval,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChangeLogFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setLogFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
const paginationFilter = value?.items?.find(
|
||||
(item) => item.key?.key === 'id',
|
||||
);
|
||||
const newFilters = value?.items?.filter(
|
||||
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
|
||||
);
|
||||
|
||||
if (newFilters && newFilters?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(newFilters || []),
|
||||
...(paginationFilter ? [paginationFilter] : []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChangeTracesFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setTracesFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
|
||||
if (value?.items && value?.items?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(value?.items?.filter((item) => item.key?.key !== 'host.name') || []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
view: selectedView,
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const filtersWithoutPagination = {
|
||||
...logFilters,
|
||||
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: filtersWithoutPagination,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
window.open(
|
||||
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
|
||||
'_blank',
|
||||
);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
filters: tracesFilters,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
window.open(
|
||||
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setSelectedInterval(selectedTime as Time);
|
||||
lastSelectedInterval.current = null;
|
||||
setSearchParams({});
|
||||
|
||||
if (selectedTime !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(selectedTime);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
setSelectedView(VIEW_TYPES.METRICS);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="70%"
|
||||
title={
|
||||
<>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">{host?.hostName}</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
open={!!host}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="host-detail-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
{host && (
|
||||
<>
|
||||
<div className="host-detail-drawer__host">
|
||||
<div className="host-details-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
STATUS
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
OPERATING SYSTEM
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
CPU USAGE
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
MEMORY USAGE
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
<Tag
|
||||
bordered
|
||||
className={`infra-monitoring-tags ${
|
||||
host.active ? 'active' : 'inactive'
|
||||
}`}
|
||||
>
|
||||
{host.active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</Tag>
|
||||
{host.os ? (
|
||||
<Tag className="infra-monitoring-tags" bordered>
|
||||
{host.os}
|
||||
</Tag>
|
||||
) : (
|
||||
<Typography.Text>-</Typography.Text>
|
||||
)}
|
||||
<div className="progress-container">
|
||||
<Progress
|
||||
percent={Number((host.cpu * 100).toFixed(1))}
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const cpuPercent = Number((host.cpu * 100).toFixed(1));
|
||||
if (cpuPercent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (cpuPercent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-container">
|
||||
<Progress
|
||||
percent={Number((host.memory * 100).toFixed(1))}
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const memoryPercent = Number((host.memory * 100).toFixed(1));
|
||||
if (memoryPercent >= 90) {
|
||||
return Color.BG_CHERRY_500;
|
||||
}
|
||||
if (memoryPercent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="views-tabs-container">
|
||||
<Radio.Group
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.METRICS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<BarChart2 size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.LOGS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.TRACES}
|
||||
>
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.CONTAINERS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.CONTAINERS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Package2 size={14} />
|
||||
Containers
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.PROCESSES ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.PROCESSES}
|
||||
>
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Processes
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{(selectedView === VIEW_TYPES.LOGS ||
|
||||
selectedView === VIEW_TYPES.TRACES) && (
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === VIEW_TYPES.METRICS && (
|
||||
<Metrics
|
||||
selectedInterval={selectedInterval}
|
||||
hostName={host.hostName}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<HostMetricLogsDetailedView
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
handleTimeChange={handleTimeChange}
|
||||
handleChangeLogFilters={handleChangeLogFilters}
|
||||
logFilters={logFilters}
|
||||
selectedInterval={selectedInterval}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<HostMetricTraces
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
handleTimeChange={handleTimeChange}
|
||||
handleChangeTracesFilters={handleChangeTracesFilters}
|
||||
tracesFilters={tracesFilters}
|
||||
selectedInterval={selectedInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTAINERS && <Containers />}
|
||||
{selectedView === VIEW_TYPES.PROCESSES && <Processes />}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricsDetails;
|
||||
@@ -0,0 +1,119 @@
|
||||
.host-metrics-logs-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
.filter-section {
|
||||
flex: 1;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-tag .ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.host-metrics-logs {
|
||||
margin-top: 1rem;
|
||||
|
||||
.virtuoso-list {
|
||||
overflow-y: hidden !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-row {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-container {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-container {
|
||||
flex: 1;
|
||||
height: calc(100vh - 272px) !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.raw-log-content {
|
||||
width: 100%;
|
||||
text-wrap: inherit;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-card {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-loading-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
.ant-skeleton-input-sm {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-logs-found {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEWS } from '../constants';
|
||||
import HostMetricsLogs from './HostMetricsLogs';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void;
|
||||
logFilters: IBuilderQuery['filters'];
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricLogsDetailedView({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
handleChangeLogFilters,
|
||||
logFilters,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items:
|
||||
logFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, logFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs-container">
|
||||
<div className="host-metrics-logs-header">
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HostMetricsLogs timeRange={timeRange} filters={logFilters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricLogsDetailedView;
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Card } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getHostLogsQueryPayload } from './constants';
|
||||
import NoLogsContainer from './NoLogsContainer';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
filters: IBuilderQuery['filters'];
|
||||
}
|
||||
|
||||
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const basePayload = getHostLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
);
|
||||
const {
|
||||
logs,
|
||||
hasReachedEndOfLogs,
|
||||
isPaginating,
|
||||
currentPage,
|
||||
setIsPaginating,
|
||||
handleNewData,
|
||||
loadMoreLogs,
|
||||
queryPayload,
|
||||
} = useHandleLogsPagination({
|
||||
timeRange,
|
||||
filters,
|
||||
excludeFilterKeys: ['host.name'],
|
||||
basePayload,
|
||||
});
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: [
|
||||
'hostMetricsLogs',
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
currentPage,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!queryPayload,
|
||||
keepPreviousData: isPaginating,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.newResult?.data?.result) {
|
||||
handleNewData(data.payload.data.newResult.data.result);
|
||||
}
|
||||
}, [data, handleNewData]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPaginating(false);
|
||||
}, [data, setIsPaginating]);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
return (
|
||||
<div key={logToRender.id}>
|
||||
<RawLogView
|
||||
isTextOverflowEllipsisDisabled
|
||||
data={logToRender}
|
||||
linesPerRow={5}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
isActiveLog={activeLog?.id === logToRender.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeLog, handleSetActiveLog, handleCloseLogDetail],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback(
|
||||
(): JSX.Element | null => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<div className="logs-loading-skeleton"> Loading more logs ... </div>
|
||||
) : hasReachedEndOfLogs ? (
|
||||
<div className="logs-loading-skeleton"> *** End *** </div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
[isFetching, hasReachedEndOfLogs],
|
||||
);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<Card bordered={false} className="host-metrics-logs-list-card">
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="host-metrics-logs-virtuoso"
|
||||
key="host-metrics-logs-virtuoso"
|
||||
ref={virtuosoRef}
|
||||
data={logs}
|
||||
endReached={loadMoreLogs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</Card>
|
||||
),
|
||||
[logs, loadMoreLogs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs">
|
||||
{isLoading && <LogsLoading />}
|
||||
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
|
||||
{isError && !isLoading && <LogsError />}
|
||||
{!isLoading && !isError && logs.length > 0 && (
|
||||
<div
|
||||
className="host-metrics-logs-list-container"
|
||||
data-log-detail-ignore="true"
|
||||
>
|
||||
{renderContent}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricsLogs;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { Ghost } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function NoLogsContainer(): React.ReactElement {
|
||||
return (
|
||||
<div className="no-logs-found">
|
||||
<Text type="secondary">
|
||||
<Ghost size={24} color={Color.BG_AMBER_500} /> No logs found for this host
|
||||
in the selected time range.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getHostLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.host-metrics-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.host-metrics-card {
|
||||
margin: 8px 0 1rem 0;
|
||||
height: 300px;
|
||||
padding: 10px;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-data-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
233
frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx
Normal file
233
frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
hostWidgetInfo,
|
||||
} from 'container/LogDetailedView/InfraMetrics/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { FeatureKeys } from '../../../constants/features';
|
||||
import { useAppContext } from '../../../providers/App/App';
|
||||
|
||||
import './Metrics.styles.scss';
|
||||
|
||||
interface MetricsTabProps {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
|
||||
hostName: string;
|
||||
}
|
||||
|
||||
function Metrics({
|
||||
selectedInterval,
|
||||
hostName,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
isModalTimeSelection,
|
||||
}: MetricsTabProps): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const {
|
||||
visibilities,
|
||||
setElement,
|
||||
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() =>
|
||||
getHostQueryPayload(
|
||||
hostName,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
dotMetricsEnabled,
|
||||
),
|
||||
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const [graphTimeIntervals, setGraphTimeIntervals] = useState<
|
||||
{
|
||||
start: number;
|
||||
end: number;
|
||||
}[]
|
||||
>(
|
||||
new Array(queries.length).fill({
|
||||
start: timeRange.startTime,
|
||||
end: timeRange.endTime,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGraphTimeIntervals(
|
||||
new Array(queries.length).fill({
|
||||
start: timeRange.startTime,
|
||||
end: timeRange.endTime,
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange]);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number, graphIndex: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
setGraphTimeIntervals((prev) => {
|
||||
const newIntervals = [...prev];
|
||||
newIntervals[graphIndex] = {
|
||||
start: Math.floor(startTimestamp / 1000),
|
||||
end: Math.floor(endTimestamp / 1000),
|
||||
};
|
||||
return newIntervals;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, idx) =>
|
||||
getUPlotChartOptions({
|
||||
apiResponse: data?.payload,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
yAxisUnit: hostWidgetInfo[idx].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: graphTimeIntervals[idx].start,
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
queries,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
graphTimeIntervals,
|
||||
onDragSelect,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
|
||||
idx: number,
|
||||
): JSX.Element => {
|
||||
if ((!query.data && query.isLoading) || !visibilities[idx]) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
const errorMessage =
|
||||
(query.error as Error)?.message || 'Something went wrong';
|
||||
return <div>{errorMessage}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cx('chart-container', {
|
||||
'no-data-container':
|
||||
!query.isLoading && !query?.data?.payload?.data?.result?.length,
|
||||
})}
|
||||
>
|
||||
<Uplot options={options[idx]} data={chartData[idx]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="metrics-header">
|
||||
<div className="metrics-datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={24} className="host-metrics-container">
|
||||
{queries.map((query, idx) => (
|
||||
<Col ref={setElement(idx)} span={12} key={hostWidgetInfo[idx].title}>
|
||||
<Typography.Text>{hostWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="host-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metrics;
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space, Typography } from 'antd';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
|
||||
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import './Processes.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityProcesses(): JSX.Element {
|
||||
function Processes(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
@@ -42,4 +43,4 @@ function EntityProcesses(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityProcesses;
|
||||
export default Processes;
|
||||
17
frontend/src/components/HostMetricsDetail/constants.ts
Normal file
17
frontend/src/components/HostMetricsDetail/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export enum VIEWS {
|
||||
METRICS = 'metrics',
|
||||
LOGS = 'logs',
|
||||
TRACES = 'traces',
|
||||
CONTAINERS = 'containers',
|
||||
PROCESSES = 'processes',
|
||||
EVENTS = 'events',
|
||||
}
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
METRICS: VIEWS.METRICS,
|
||||
LOGS: VIEWS.LOGS,
|
||||
TRACES: VIEWS.TRACES,
|
||||
CONTAINERS: VIEWS.CONTAINERS,
|
||||
PROCESSES: VIEWS.PROCESSES,
|
||||
EVENTS: VIEWS.EVENTS,
|
||||
};
|
||||
3
frontend/src/components/HostMetricsDetail/index.tsx
Normal file
3
frontend/src/components/HostMetricsDetail/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HostMetricsDetails from './HostMetricsDetails';
|
||||
|
||||
export default HostMetricsDetails;
|
||||
@@ -100,19 +100,16 @@ function MarkdownRenderer({
|
||||
variables,
|
||||
trackCopyAction,
|
||||
elementDetails,
|
||||
className,
|
||||
}: {
|
||||
markdownContent: any;
|
||||
variables: any;
|
||||
trackCopyAction?: boolean;
|
||||
elementDetails?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}): JSX.Element {
|
||||
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
rehypePlugins={[rehypeRaw as any]}
|
||||
components={{
|
||||
// @ts-ignore
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
/* Override the background and color of the dialog content from the theme */
|
||||
> div {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cmdk-section-heading [cmdk-group-heading] {
|
||||
@@ -51,22 +43,6 @@
|
||||
|
||||
.cmdk-item {
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-item] svg {
|
||||
|
||||
@@ -65,7 +65,6 @@ const ROUTES = {
|
||||
WORKSPACE_SUSPENDED: '/workspace-suspended',
|
||||
SHORTCUTS: '/settings/shortcuts',
|
||||
INTEGRATIONS: '/integrations',
|
||||
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
|
||||
MESSAGING_QUEUES_BASE: '/messaging-queues',
|
||||
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
|
||||
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { UnfoldVertical } from 'lucide-react';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
@@ -95,14 +94,20 @@ function DependentServices({
|
||||
}}
|
||||
onRow={(record): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => {
|
||||
const serviceName =
|
||||
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
|
||||
? record.serviceData.serviceName
|
||||
: '';
|
||||
const url = new URL(
|
||||
`/services/${
|
||||
record.serviceData.serviceName &&
|
||||
record.serviceData.serviceName !== '-'
|
||||
? record.serviceData.serviceName
|
||||
: ''
|
||||
}`,
|
||||
window.location.origin,
|
||||
);
|
||||
const urlQuery = new URLSearchParams();
|
||||
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
|
||||
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
|
||||
url.search = urlQuery.toString();
|
||||
window.open(url.toString(), '_blank');
|
||||
},
|
||||
className: 'clickable-row',
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
IntegrationType,
|
||||
RequestIntegrationBtn,
|
||||
} from 'pages/Integrations/RequestIntegrationBtn';
|
||||
|
||||
import Header from './Header/Header';
|
||||
import HeroSection from './HeroSection/HeroSection';
|
||||
import ServicesTabs from './ServicesSection/ServicesTabs';
|
||||
|
||||
function CloudIntegrationPage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<HeroSection />
|
||||
<RequestIntegrationBtn
|
||||
type={IntegrationType.AWS_SERVICES}
|
||||
message="Can't find the AWS service you're looking for? Request more integrations"
|
||||
/>
|
||||
<ServicesTabs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloudIntegrationPage;
|
||||
@@ -3,7 +3,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
&__navigation {
|
||||
display: flex;
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
&__breadcrumb-title {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
@@ -30,8 +30,8 @@
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 10px;
|
||||
@@ -39,11 +39,9 @@
|
||||
width: 113px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import Breadcrumb from 'antd/es/breadcrumb';
|
||||
import { Breadcrumb } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { IntegrationType } from 'container/Integrations/types';
|
||||
import { Blocks, LifeBuoy } from 'lucide-react';
|
||||
|
||||
import './Header.styles.scss';
|
||||
|
||||
function Header({ title }: { title: IntegrationType }): JSX.Element {
|
||||
function Header(): JSX.Element {
|
||||
return (
|
||||
<div className="cloud-header">
|
||||
<div className="cloud-header__navigation">
|
||||
@@ -18,33 +16,32 @@ function Header({ title }: { title: IntegrationType }): JSX.Element {
|
||||
title: (
|
||||
<Link to={ROUTES.INTEGRATIONS}>
|
||||
<span className="cloud-header__breadcrumb-link">
|
||||
<Blocks size={16} color="var(--l2-foreground)" />
|
||||
<Blocks size={16} color="var(--bg-vanilla-400)" />
|
||||
<span className="cloud-header__breadcrumb-title">Integrations</span>
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
|
||||
title: (
|
||||
<div className="cloud-header__breadcrumb-title">
|
||||
Amazon Web Services
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="cloud-header__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
window.open(
|
||||
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
prefixIcon={<LifeBuoy size={12} />}
|
||||
<a
|
||||
href="https://signoz.io/blog/native-aws-integrations-with-autodiscovery/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cloud-header__help"
|
||||
>
|
||||
<LifeBuoy size={12} />
|
||||
Get Help
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,10 +1,8 @@
|
||||
.hero-section {
|
||||
padding: 16px;
|
||||
|
||||
height: 308px;
|
||||
padding: 26px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-position: right;
|
||||
@@ -32,36 +30,7 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
&__icon {
|
||||
height: fit-content;
|
||||
background-color: var(--l1-background);
|
||||
padding: 12px;
|
||||
border: 1px solid var(--l2-background);
|
||||
border-radius: 6px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&-title {
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
@@ -69,7 +38,7 @@
|
||||
letter-spacing: -0.12px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
.description {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import integrationsHeroBgUrl from '@/assets/Images/integrations-hero-bg.png';
|
||||
import awsDarkUrl from '@/assets/Logos/aws-dark.svg';
|
||||
|
||||
import AccountActions from './components/AccountActions';
|
||||
|
||||
import './HeroSection.style.scss';
|
||||
|
||||
function HeroSection(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
return (
|
||||
<div
|
||||
className="hero-section"
|
||||
style={
|
||||
isDarkMode
|
||||
? {
|
||||
backgroundImage: `url('${integrationsHeroBgUrl}')`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="hero-section__icon">
|
||||
<img src={awsDarkUrl} alt="aws-logo" />
|
||||
</div>
|
||||
<div className="hero-section__details">
|
||||
<div className="title">Amazon Web Services</div>
|
||||
<div className="description">
|
||||
One-click setup for AWS monitoring with SigNoz
|
||||
</div>
|
||||
<AccountActions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeroSection;
|
||||
@@ -4,57 +4,14 @@
|
||||
|
||||
&-with-account {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l3-background);
|
||||
background: var(--l1-background);
|
||||
|
||||
.selected-cloud-integration-account-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid var(--l3-background);
|
||||
border-radius: none;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
&-selector-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.account-selector-label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.account-selector {
|
||||
.ant-select {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.ant-select-selector {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-skeleton {
|
||||
width: 300px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__new-account-button-skeleton {
|
||||
@@ -65,13 +22,11 @@
|
||||
&__account-settings-button-skeleton {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
&__action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__action-button {
|
||||
font-family: 'Inter';
|
||||
border-radius: 2px;
|
||||
@@ -90,16 +45,11 @@
|
||||
&.secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,30 +57,25 @@
|
||||
.cloud-account-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l3-background);
|
||||
background: var(--l1-background);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
.ant-select-selector {
|
||||
border-color: var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
padding: 6px 8px !important;
|
||||
min-width: 140px !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active {
|
||||
background: var(--l3-background) !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
&:hover {
|
||||
.ant-select-selector {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.account-option-item {
|
||||
display: flex;
|
||||
@@ -142,8 +87,60 @@
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
background-color: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--border) 20%,
|
||||
transparent
|
||||
); /* #C0C1C3 with 0.2 opacity */
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.lightMode {
|
||||
.hero-section__action-button {
|
||||
&.primary {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-account-selector {
|
||||
background: var(--l1-background);
|
||||
.ant-select-selector {
|
||||
background: var(--l1-background) !important;
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
.ant-select-item-option-active {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-select-selector {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account-option-item {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&__selected {
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,58 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Select, Skeleton } from 'antd';
|
||||
import { SelectProps } from 'antd/lib';
|
||||
import { Button, Select, Skeleton } from 'antd';
|
||||
import type { SelectProps } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
|
||||
import { CloudAccount } from '../../types';
|
||||
import { CloudAccount } from '../../ServicesSection/types';
|
||||
import AccountSettingsModal from './AccountSettingsModal';
|
||||
import CloudAccountSetupModal from './CloudAccountSetupModal';
|
||||
|
||||
import './AccountActions.style.scss';
|
||||
|
||||
interface AccountOptionItemProps {
|
||||
label: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function AccountOptionItem({
|
||||
label,
|
||||
isSelected,
|
||||
}: AccountOptionItemProps): JSX.Element {
|
||||
return (
|
||||
<div className="account-option-item">
|
||||
{label}
|
||||
{isSelected && (
|
||||
<div className="account-option-item__selected">
|
||||
<Check size={12} color={Color.BG_VANILLA_100} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOption(
|
||||
option: any,
|
||||
activeAccountId: string | undefined,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<AccountOptionItem
|
||||
label={option.label}
|
||||
isSelected={option.value === activeAccountId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getAccountById = (
|
||||
accounts: CloudAccount[],
|
||||
accountId: string,
|
||||
): CloudAccount | null =>
|
||||
accounts.find((account) => account.cloud_account_id === accountId) || null;
|
||||
|
||||
function AccountActionsRenderer({
|
||||
accounts,
|
||||
isLoading,
|
||||
@@ -38,52 +73,55 @@ function AccountActionsRenderer({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="hero-section__actions-with-account">
|
||||
<Skeleton.Input active block className="hero-section__input-skeleton" />
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="large"
|
||||
block
|
||||
className="hero-section__input-skeleton"
|
||||
/>
|
||||
<div className="hero-section__action-buttons">
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="large"
|
||||
className="hero-section__new-account-button-skeleton"
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="large"
|
||||
className="hero-section__account-settings-button-skeleton"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts?.length) {
|
||||
return (
|
||||
<div className="hero-section__actions-with-account">
|
||||
<div className="hero-section__actions-with-account-selector-container">
|
||||
<div className="selected-cloud-integration-account-status">
|
||||
<Dot size={24} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
|
||||
<div className="account-selector-label">Account:</div>
|
||||
|
||||
<span className="account-selector">
|
||||
<Select
|
||||
value={activeAccount?.providerAccountId}
|
||||
options={selectOptions}
|
||||
rootClassName="cloud-account-selector"
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder="Select AWS Account"
|
||||
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
|
||||
onChange={onAccountChange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={`Account: ${activeAccount?.cloud_account_id}`}
|
||||
options={selectOptions}
|
||||
rootClassName="cloud-account-selector"
|
||||
placeholder="Select AWS Account"
|
||||
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
|
||||
optionRender={(option): JSX.Element =>
|
||||
renderOption(option, activeAccount?.cloud_account_id)
|
||||
}
|
||||
onChange={onAccountChange}
|
||||
/>
|
||||
<div className="hero-section__action-buttons">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefixIcon={<PencilLine size={14} />}
|
||||
type="primary"
|
||||
className="hero-section__action-button primary"
|
||||
onClick={onIntegrationModalOpen}
|
||||
>
|
||||
Add New AWS Account
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
className="hero-section__action-button secondary"
|
||||
onClick={onAccountSettingsModalOpen}
|
||||
>
|
||||
Edit Account
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={onIntegrationModalOpen}
|
||||
prefixIcon={<Plus size={14} />}
|
||||
>
|
||||
Add New Account
|
||||
Account Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,11 +129,8 @@ function AccountActionsRenderer({
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefixIcon={<Plug size={14} />}
|
||||
className="hero-section__action-button primary"
|
||||
onClick={onIntegrationModalOpen}
|
||||
size="sm"
|
||||
>
|
||||
Integrate Now
|
||||
</Button>
|
||||
@@ -105,18 +140,7 @@ function AccountActionsRenderer({
|
||||
function AccountActions(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const navigate = useNavigate();
|
||||
const { data: listAccountsResponse, isLoading } = useListAccounts({
|
||||
cloudProvider: INTEGRATION_TYPES.AWS,
|
||||
});
|
||||
const accounts = useMemo((): CloudAccount[] | undefined => {
|
||||
const raw = listAccountsResponse?.data?.accounts;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw
|
||||
.map(mapAccountDtoToAwsCloudAccount)
|
||||
.filter((account): account is CloudAccount => account !== null);
|
||||
}, [listAccountsResponse]);
|
||||
const { data: accounts, isLoading } = useAwsAccounts();
|
||||
|
||||
const initialAccount = useMemo(
|
||||
() =>
|
||||
@@ -138,13 +162,7 @@ function AccountActions(): JSX.Element {
|
||||
const latestUrlQuery = new URLSearchParams(window.location.search);
|
||||
latestUrlQuery.set('cloudAccountId', initialAccount.cloud_account_id);
|
||||
navigate({ search: latestUrlQuery.toString() });
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveAccount(null);
|
||||
const latestUrlQuery = new URLSearchParams(window.location.search);
|
||||
latestUrlQuery.delete('cloudAccountId');
|
||||
navigate({ search: latestUrlQuery.toString() });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialAccount]);
|
||||
|
||||
@@ -180,7 +198,7 @@ function AccountActions(): JSX.Element {
|
||||
accounts?.length
|
||||
? accounts.map((account) => ({
|
||||
value: account.cloud_account_id,
|
||||
label: account.providerAccountId,
|
||||
label: account.cloud_account_id,
|
||||
}))
|
||||
: [],
|
||||
[accounts],
|
||||
@@ -210,10 +228,10 @@ function AccountActions(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAccountSettingsModalOpen && activeAccount && (
|
||||
{isAccountSettingsModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={(): void => setIsAccountSettingsModalOpen(false)}
|
||||
account={activeAccount}
|
||||
account={activeAccount as CloudAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
)}
|
||||
@@ -14,13 +14,8 @@
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 14px;
|
||||
|
||||
&-account-info {
|
||||
&-connected-account-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
@@ -43,12 +38,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-region-selector {
|
||||
&-regions-switch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
gap: 10px;
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
@@ -56,14 +49,6 @@
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -81,17 +66,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
&-regions-select {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
|
||||
&-close-button,
|
||||
&-save-button {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -100,31 +83,18 @@
|
||||
}
|
||||
&-close-button {
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
background: var(--l1-border);
|
||||
border: none;
|
||||
}
|
||||
&-save-button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
margin: 0 !important;
|
||||
|
||||
&:disabled {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
opacity: 0.6;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
border-radius: 2px;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
.ant-modal-body {
|
||||
@@ -139,3 +109,81 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.account-settings-modal {
|
||||
&__title-account-id {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
&-account-info {
|
||||
&-connected-account-details {
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&-account-id {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&-account-id {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-regions-switch {
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&-switch {
|
||||
&-label {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
&-close-button,
|
||||
&-save-button {
|
||||
color: var(--l1-background);
|
||||
}
|
||||
|
||||
&-close-button {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&-save-button {
|
||||
// Keep primary button same as dark mode
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:disabled {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Dispatch, SetStateAction, useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Form, Select, Switch } from 'antd';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
getRegionPreviewText,
|
||||
useAccountSettingsModal,
|
||||
} from 'hooks/integration/aws/useAccountSettingsModal';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
|
||||
import logEvent from '../../../../api/common/logEvent';
|
||||
import { CloudAccount } from '../../ServicesSection/types';
|
||||
import { RegionSelector } from './RegionSelector';
|
||||
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
|
||||
|
||||
import './AccountSettingsModal.style.scss';
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
account: CloudAccount;
|
||||
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
|
||||
}
|
||||
|
||||
function AccountSettingsModal({
|
||||
onClose,
|
||||
account,
|
||||
setActiveAccount,
|
||||
}: AccountSettingsModalProps): JSX.Element {
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
isRegionSelectOpen,
|
||||
isSaveDisabled,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
setIsRegionSelectOpen,
|
||||
handleIncludeAllRegionsChange,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const handleRemoveIntegrationAccountSuccess = (): void => {
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
|
||||
urlQuery.delete('cloudAccountId');
|
||||
handleClose();
|
||||
history.replace({ search: urlQuery.toString() });
|
||||
|
||||
logEvent('AWS Integration: Account removed', {
|
||||
id: account?.id,
|
||||
cloudAccountId: account?.cloud_account_id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegionDeselect = useCallback(
|
||||
(item: string): void => {
|
||||
if (selectedRegions.includes(item)) {
|
||||
setSelectedRegions(selectedRegions.filter((region) => region !== item));
|
||||
if (includeAllRegions) {
|
||||
setIncludeAllRegions(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
],
|
||||
);
|
||||
|
||||
const renderRegionSelector = useCallback(() => {
|
||||
if (isRegionSelectOpen) {
|
||||
return (
|
||||
<RegionSelector
|
||||
selectedRegions={selectedRegions}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="account-settings-modal__body-regions-switch-switch ">
|
||||
<Switch
|
||||
checked={includeAllRegions}
|
||||
onChange={handleIncludeAllRegionsChange}
|
||||
/>
|
||||
<button
|
||||
className="account-settings-modal__body-regions-switch-switch-label"
|
||||
type="button"
|
||||
onClick={(): void => handleIncludeAllRegionsChange(!includeAllRegions)}
|
||||
>
|
||||
Include all regions
|
||||
</button>
|
||||
</div>
|
||||
<Select
|
||||
suffixIcon={null}
|
||||
placeholder="Select Region(s)"
|
||||
className="cloud-account-setup-form__select account-settings-modal__body-regions-select integrations-select"
|
||||
onClick={(): void => setIsRegionSelectOpen(true)}
|
||||
mode="multiple"
|
||||
maxTagCount={3}
|
||||
value={getRegionPreviewText(selectedRegions)}
|
||||
open={false}
|
||||
onDeselect={handleRegionDeselect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
isRegionSelectOpen,
|
||||
includeAllRegions,
|
||||
handleIncludeAllRegionsChange,
|
||||
selectedRegions,
|
||||
handleRegionDeselect,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
setIsRegionSelectOpen,
|
||||
]);
|
||||
|
||||
const renderAccountDetails = useCallback(
|
||||
() => (
|
||||
<div className="account-settings-modal__body-account-info">
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details">
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details-title">
|
||||
Connected Account details
|
||||
</div>
|
||||
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
|
||||
AWS Account:{' '}
|
||||
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
|
||||
{account?.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[account?.id],
|
||||
);
|
||||
|
||||
const modalTitle = (
|
||||
<div className="account-settings-modal__title">
|
||||
Account settings for{' '}
|
||||
<span className="account-settings-modal__title-account-id">
|
||||
{account?.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open
|
||||
title={modalTitle}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSubmit}
|
||||
okText="Save"
|
||||
okButtonProps={{
|
||||
disabled: isSaveDisabled,
|
||||
className: 'account-settings-modal__footer-save-button',
|
||||
loading: isLoading,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
className: 'account-settings-modal__footer-close-button',
|
||||
}}
|
||||
width={672}
|
||||
rootClassName="account-settings-modal"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
}}
|
||||
>
|
||||
<div className="account-settings-modal__body">
|
||||
{renderAccountDetails()}
|
||||
|
||||
<Form.Item
|
||||
name="selectedRegions"
|
||||
rules={[
|
||||
{
|
||||
validator: async (): Promise<void> => {
|
||||
if (selectedRegions.length === 0) {
|
||||
throw new Error('Please select at least one region to monitor');
|
||||
}
|
||||
},
|
||||
message: 'Please select at least one region to monitor',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderRegionSelector()}
|
||||
</Form.Item>
|
||||
|
||||
<div className="integration-detail-content">
|
||||
<RemoveIntegrationAccount
|
||||
accountId={account?.id}
|
||||
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettingsModal;
|
||||
@@ -1,33 +1,4 @@
|
||||
.cloud-account-setup-modal {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
[data-slot='drawer-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.account-setup-modal-footer {
|
||||
&__confirm-button {
|
||||
background: var(--primary-background);
|
||||
@@ -39,24 +10,16 @@
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
&__close-button {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-account-setup-form {
|
||||
padding: 16px;
|
||||
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -93,8 +56,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.retry-time {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 14px;
|
||||
@@ -155,7 +116,7 @@
|
||||
}
|
||||
&__note {
|
||||
padding: 12px;
|
||||
color: var(--callout-primary-description);
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.06px;
|
||||
@@ -183,3 +144,87 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.cloud-account-setup-modal {
|
||||
.account-setup-modal-footer {
|
||||
&__confirm-button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-account-setup-form {
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__select {
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__include-all-regions-switch {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&-label {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
color: var(--primary-foreground);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--primary-background) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
|
||||
}
|
||||
|
||||
&__submit-button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
&__alert {
|
||||
&.ant-alert-error {
|
||||
color: var(--danger-foreground);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
}
|
||||
|
||||
&.ant-alert-warning {
|
||||
color: var(--warning-foreground);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--warning-background) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--warning-background) 10%, transparent);
|
||||
}
|
||||
|
||||
&-message {
|
||||
.retry-time {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
|
||||
import { SquareArrowOutUpRight } from 'lucide-react';
|
||||
|
||||
@@ -11,15 +12,19 @@ import {
|
||||
ModalStateEnum,
|
||||
} from '../types';
|
||||
import { RegionForm } from './RegionForm';
|
||||
import { RegionSelector } from './RegionSelector';
|
||||
import { SuccessView } from './SuccessView';
|
||||
|
||||
import './CloudAccountSetupModal.style.scss';
|
||||
|
||||
function CloudAccountSetupModal({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
form,
|
||||
modalState,
|
||||
setModalState,
|
||||
isLoading,
|
||||
activeView,
|
||||
selectedRegions,
|
||||
@@ -27,86 +32,97 @@ function CloudAccountSetupModal({
|
||||
isGeneratingUrl,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
handleIncludeAllRegionsChange,
|
||||
handleRegionSelect,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
setActiveView,
|
||||
allRegions,
|
||||
accountId,
|
||||
selectedDeploymentRegion,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
} = useIntegrationModal({ onClose });
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
return (
|
||||
<div className="cloud-account-setup-modal__content">
|
||||
<RegionForm
|
||||
form={form}
|
||||
modalState={modalState}
|
||||
if (modalState === ModalStateEnum.SUCCESS) {
|
||||
return <SuccessView />;
|
||||
}
|
||||
|
||||
if (activeView === ActiveViewEnum.SELECT_REGIONS) {
|
||||
return (
|
||||
<RegionSelector
|
||||
selectedRegions={selectedRegions}
|
||||
includeAllRegions={includeAllRegions}
|
||||
onRegionSelect={handleRegionSelect}
|
||||
onSubmit={handleSubmit}
|
||||
accountId={accountId}
|
||||
handleRegionChange={handleRegionChange}
|
||||
connectionParams={connectionParams}
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
onConnectionSuccess={handleConnectionSuccess}
|
||||
onConnectionTimeout={handleConnectionTimeout}
|
||||
onConnectionError={handleConnectionError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="cloud-account-setup-modal__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefixIcon={
|
||||
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
selectedRegions.length === 0 ||
|
||||
isLoading ||
|
||||
isGeneratingUrl ||
|
||||
modalState === ModalStateEnum.WAITING
|
||||
}
|
||||
>
|
||||
Launch Cloud Formation Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<RegionForm
|
||||
form={form}
|
||||
modalState={modalState}
|
||||
setModalState={setModalState}
|
||||
selectedRegions={selectedRegions}
|
||||
includeAllRegions={includeAllRegions}
|
||||
onIncludeAllRegionsChange={handleIncludeAllRegionsChange}
|
||||
onRegionSelect={handleRegionSelect}
|
||||
onSubmit={handleSubmit}
|
||||
accountId={accountId}
|
||||
selectedDeploymentRegion={selectedDeploymentRegion}
|
||||
handleRegionChange={handleRegionChange}
|
||||
connectionParams={connectionParams}
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
modalState,
|
||||
activeView,
|
||||
form,
|
||||
setModalState,
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
handleIncludeAllRegionsChange,
|
||||
handleRegionSelect,
|
||||
handleSubmit,
|
||||
accountId,
|
||||
selectedDeploymentRegion,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
isLoading,
|
||||
isGeneratingUrl,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
]);
|
||||
|
||||
const getSelectedRegionsCount = useCallback(
|
||||
(): number => selectedRegions.length,
|
||||
[selectedRegions],
|
||||
(): number =>
|
||||
selectedRegions.includes('all') ? allRegions.length : selectedRegions.length,
|
||||
[selectedRegions, allRegions],
|
||||
);
|
||||
|
||||
const getModalConfig = useCallback(() => {
|
||||
// Handle success state first
|
||||
if (modalState === ModalStateEnum.SUCCESS) {
|
||||
return {
|
||||
title: 'AWS Integration',
|
||||
okText: (
|
||||
<div className="cloud-account-setup-success-view__footer-button">
|
||||
Continue
|
||||
</div>
|
||||
),
|
||||
block: true,
|
||||
onOk: (): void => {
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
|
||||
handleClose();
|
||||
},
|
||||
cancelButtonProps: { style: { display: 'none' } },
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle other views
|
||||
const viewConfigs = {
|
||||
[ActiveViewEnum.FORM]: {
|
||||
title: 'Add AWS Account',
|
||||
@@ -139,30 +155,35 @@ function CloudAccountSetupModal({
|
||||
isLoading,
|
||||
isGeneratingUrl,
|
||||
activeView,
|
||||
handleClose,
|
||||
setActiveView,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const modalConfig = getModalConfig();
|
||||
|
||||
const handleDrawerOpenChange = (open: boolean): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
type="panel"
|
||||
<SignozModal
|
||||
open
|
||||
className="cloud-account-setup-modal"
|
||||
content={renderContent()}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
header={{
|
||||
title: modalConfig.title,
|
||||
title={modalConfig.title}
|
||||
onCancel={handleClose}
|
||||
onOk={modalConfig.onOk}
|
||||
okText={modalConfig.okText}
|
||||
okButtonProps={{
|
||||
loading: isLoading,
|
||||
disabled: selectedRegions.length === 0 || modalConfig.disabled,
|
||||
className:
|
||||
activeView === ActiveViewEnum.FORM
|
||||
? 'cloud-account-setup-form__submit-button'
|
||||
: 'account-setup-modal-footer__confirm-button',
|
||||
block: activeView === ActiveViewEnum.FORM,
|
||||
}}
|
||||
/>
|
||||
cancelButtonProps={modalConfig.cancelButtonProps}
|
||||
width={672}
|
||||
>
|
||||
{renderContent()}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Form, Select } from 'antd';
|
||||
import { Form, Select, Switch } from 'antd';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Region } from 'utils/regions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { RegionSelector } from './RegionSelector';
|
||||
|
||||
// Form section components
|
||||
function RegionDeploymentSection({
|
||||
regions,
|
||||
selectedDeploymentRegion,
|
||||
handleRegionChange,
|
||||
isFormDisabled,
|
||||
}: {
|
||||
regions: Region[];
|
||||
selectedDeploymentRegion: string | undefined;
|
||||
handleRegionChange: (value: string) => void;
|
||||
isFormDisabled: boolean;
|
||||
}): JSX.Element {
|
||||
@@ -35,8 +33,8 @@ function RegionDeploymentSection({
|
||||
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
|
||||
className="cloud-account-setup-form__select integrations-select"
|
||||
onChange={handleRegionChange}
|
||||
value={selectedDeploymentRegion}
|
||||
disabled={isFormDisabled}
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
{regions.flatMap((region) =>
|
||||
region.subRegions.map((subRegion) => (
|
||||
@@ -52,13 +50,19 @@ function RegionDeploymentSection({
|
||||
}
|
||||
|
||||
function MonitoringRegionsSection({
|
||||
includeAllRegions,
|
||||
selectedRegions,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
onIncludeAllRegionsChange,
|
||||
getRegionPreviewText,
|
||||
onRegionSelect,
|
||||
isFormDisabled,
|
||||
}: {
|
||||
includeAllRegions: boolean;
|
||||
selectedRegions: string[];
|
||||
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
|
||||
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
|
||||
onIncludeAllRegionsChange: (checked: boolean) => void;
|
||||
getRegionPreviewText: (regions: string[]) => string[];
|
||||
onRegionSelect: () => void;
|
||||
isFormDisabled: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
@@ -69,12 +73,51 @@ function MonitoringRegionsSection({
|
||||
Choose only the regions you want SigNoz to monitor. You can enable all at
|
||||
once, or pick specific ones:
|
||||
</div>
|
||||
|
||||
<RegionSelector
|
||||
selectedRegions={selectedRegions}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
/>
|
||||
<Form.Item
|
||||
name="monitorRegions"
|
||||
rules={[
|
||||
{
|
||||
validator: async (): Promise<void> => {
|
||||
if (selectedRegions.length === 0) {
|
||||
return Promise.reject();
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
message: 'Please select at least one region to monitor',
|
||||
},
|
||||
]}
|
||||
className="cloud-account-setup-form__form-item"
|
||||
>
|
||||
<div className="cloud-account-setup-form__include-all-regions-switch">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={includeAllRegions}
|
||||
onChange={onIncludeAllRegionsChange}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<button
|
||||
className="cloud-account-setup-form__include-all-regions-switch-label"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
!isFormDisabled
|
||||
? onIncludeAllRegionsChange(!includeAllRegions)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Include all regions
|
||||
</button>
|
||||
</div>
|
||||
<Select
|
||||
suffixIcon={null}
|
||||
placeholder="Select Region(s)"
|
||||
className="cloud-account-setup-form__select integrations-select"
|
||||
onClick={!isFormDisabled ? onRegionSelect : undefined}
|
||||
mode="multiple"
|
||||
maxTagCount={3}
|
||||
value={getRegionPreviewText(selectedRegions)}
|
||||
open={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useRef } from 'react';
|
||||
import { Form } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useAccountStatus } from 'hooks/integration/aws/useAccountStatus';
|
||||
import { AccountStatusResponse } from 'types/api/integrations/aws';
|
||||
import { regions } from 'utils/regions';
|
||||
|
||||
import logEvent from '../../../../api/common/logEvent';
|
||||
import { ModalStateEnum, RegionFormProps } from '../types';
|
||||
import AlertMessage from './AlertMessage';
|
||||
import {
|
||||
ComplianceNote,
|
||||
MonitoringRegionsSection,
|
||||
RegionDeploymentSection,
|
||||
} from './IntegrateNowFormSections';
|
||||
import RenderConnectionFields from './RenderConnectionParams';
|
||||
|
||||
const allRegions = (): string[] =>
|
||||
regions.flatMap((r) => r.subRegions.map((sr) => sr.name));
|
||||
|
||||
const getRegionPreviewText = (regions: string[]): string[] => {
|
||||
if (regions.includes('all')) {
|
||||
return allRegions();
|
||||
}
|
||||
return regions;
|
||||
};
|
||||
|
||||
export function RegionForm({
|
||||
form,
|
||||
modalState,
|
||||
setModalState,
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
onIncludeAllRegionsChange,
|
||||
onRegionSelect,
|
||||
onSubmit,
|
||||
accountId,
|
||||
selectedDeploymentRegion,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
}: RegionFormProps): JSX.Element {
|
||||
const startTimeRef = useRef(Date.now());
|
||||
const refetchInterval = 10 * 1000;
|
||||
const errorTimeout = 10 * 60 * 1000;
|
||||
|
||||
const { isLoading: isAccountStatusLoading } = useAccountStatus(accountId, {
|
||||
refetchInterval,
|
||||
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
|
||||
onSuccess: (data: AccountStatusResponse) => {
|
||||
if (data.data.status.integration.last_heartbeat_ts_ms !== null) {
|
||||
setModalState(ModalStateEnum.SUCCESS);
|
||||
logEvent('AWS Integration: Account connected', {
|
||||
cloudAccountId: data?.data?.cloud_account_id,
|
||||
status: data?.data?.status,
|
||||
});
|
||||
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
logEvent('AWS Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
},
|
||||
});
|
||||
|
||||
const isFormDisabled =
|
||||
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="cloud-account-setup-form"
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
<AlertMessage modalState={modalState} />
|
||||
|
||||
<div
|
||||
className={cx(`cloud-account-setup-form__content`, {
|
||||
disabled: isFormDisabled,
|
||||
})}
|
||||
>
|
||||
<RegionDeploymentSection
|
||||
regions={regions}
|
||||
handleRegionChange={handleRegionChange}
|
||||
isFormDisabled={isFormDisabled}
|
||||
selectedDeploymentRegion={selectedDeploymentRegion}
|
||||
/>
|
||||
<MonitoringRegionsSection
|
||||
includeAllRegions={includeAllRegions}
|
||||
selectedRegions={selectedRegions}
|
||||
onIncludeAllRegionsChange={onIncludeAllRegionsChange}
|
||||
getRegionPreviewText={getRegionPreviewText}
|
||||
onRegionSelect={onRegionSelect}
|
||||
isFormDisabled={isFormDisabled}
|
||||
/>
|
||||
<ComplianceNote />
|
||||
<RenderConnectionFields
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
isFormDisabled={isFormDisabled}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.select-all {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.regions-grid {
|
||||
@@ -20,11 +19,3 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.region-selector-footer {
|
||||
margin-top: 36px;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -28,12 +28,10 @@ export function RegionSelector({
|
||||
<div className="region-selector">
|
||||
<div className="select-all">
|
||||
<Checkbox
|
||||
checked={
|
||||
allRegionIds.length > 0 &&
|
||||
allRegionIds.every((regionId) => selectedRegions.includes(regionId))
|
||||
}
|
||||
checked={selectedRegions.includes('all')}
|
||||
indeterminate={
|
||||
selectedRegions.length > 0 && selectedRegions.length < allRegionIds.length
|
||||
selectedRegions.length > 20 &&
|
||||
selectedRegions.length < allRegionIds.length
|
||||
}
|
||||
onChange={(e): void => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
@@ -48,7 +46,10 @@ export function RegionSelector({
|
||||
{region.subRegions.map((subRegion) => (
|
||||
<Checkbox
|
||||
key={subRegion.id}
|
||||
checked={selectedRegions.includes(subRegion.id)}
|
||||
checked={
|
||||
selectedRegions.includes('all') ||
|
||||
selectedRegions.includes(subRegion.id)
|
||||
}
|
||||
onChange={(): void => handleRegionSelect(subRegion.id)}
|
||||
>
|
||||
{subRegion.name}
|
||||
@@ -0,0 +1,47 @@
|
||||
.remove-integration-account {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sakura-500) 6%, transparent);
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--bg-cherry-300);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-cherry-500);
|
||||
border: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 13.3px; /* 110.833% */
|
||||
padding: 9px 13px;
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&.ant-btn-default {
|
||||
color: var(--l2-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button, Modal } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import removeAwsIntegrationAccount from 'api/Integrations/removeAwsIntegrationAccount';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { X } from 'lucide-react';
|
||||
import { INTEGRATION_TELEMETRY_EVENTS } from 'pages/Integrations/utils';
|
||||
|
||||
import './RemoveIntegrationAccount.scss';
|
||||
|
||||
function RemoveIntegrationAccount({
|
||||
accountId,
|
||||
onRemoveIntegrationAccountSuccess,
|
||||
}: {
|
||||
accountId: string;
|
||||
onRemoveIntegrationAccountSuccess: () => void;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const showModal = (): void => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: removeIntegration,
|
||||
isLoading: isRemoveIntegrationLoading,
|
||||
} = useMutation(removeAwsIntegrationAccount, {
|
||||
onSuccess: () => {
|
||||
onRemoveIntegrationAccountSuccess?.();
|
||||
setIsModalOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.error({
|
||||
message: SOMETHING_WENT_WRONG,
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleOk = (): void => {
|
||||
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
|
||||
accountId,
|
||||
});
|
||||
removeIntegration(accountId);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="remove-integration-account">
|
||||
<div className="remove-integration-account__header">
|
||||
<div className="remove-integration-account__title">Remove Integration</div>
|
||||
<div className="remove-integration-account__subtitle">
|
||||
Removing this integration won't delete any existing data but will stop
|
||||
collecting new data from AWS.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="remove-integration-account__button"
|
||||
icon={<X size={14} />}
|
||||
onClick={(): void => showModal()}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Modal
|
||||
className="remove-integration-modal"
|
||||
open={isModalOpen}
|
||||
title="Remove integration"
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="Remove Integration"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: isRemoveIntegrationLoading,
|
||||
}}
|
||||
>
|
||||
<div className="remove-integration-modal__text">
|
||||
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.
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoveIntegrationAccount;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Form, Input } from 'antd';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ConnectionParams } from 'types/api/integrations/aws';
|
||||
|
||||
function RenderConnectionFields({
|
||||
isConnectionParamsLoading,
|
||||
@@ -7,51 +7,51 @@ function RenderConnectionFields({
|
||||
isFormDisabled,
|
||||
}: {
|
||||
isConnectionParamsLoading?: boolean;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
|
||||
connectionParams?: ConnectionParams | null;
|
||||
isFormDisabled?: boolean;
|
||||
}): JSX.Element | null {
|
||||
if (
|
||||
isConnectionParamsLoading ||
|
||||
(!!connectionParams?.ingestionUrl &&
|
||||
!!connectionParams?.ingestionKey &&
|
||||
!!connectionParams?.sigNozApiUrl &&
|
||||
!!connectionParams?.sigNozApiKey)
|
||||
(!!connectionParams?.ingestion_url &&
|
||||
!!connectionParams?.ingestion_key &&
|
||||
!!connectionParams?.signoz_api_url &&
|
||||
!!connectionParams?.signoz_api_key)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item name="connectionParams">
|
||||
{!connectionParams?.ingestionUrl && (
|
||||
<Form.Item name="connection_params">
|
||||
{!connectionParams?.ingestion_url && (
|
||||
<Form.Item
|
||||
name="ingestionUrl"
|
||||
name="ingestion_url"
|
||||
label="Ingestion URL"
|
||||
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
|
||||
>
|
||||
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.ingestionKey && (
|
||||
{!connectionParams?.ingestion_key && (
|
||||
<Form.Item
|
||||
name="ingestionKey"
|
||||
name="ingestion_key"
|
||||
label="Ingestion Key"
|
||||
rules={[{ required: true, message: 'Please enter ingestion key' }]}
|
||||
>
|
||||
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.sigNozApiUrl && (
|
||||
{!connectionParams?.signoz_api_url && (
|
||||
<Form.Item
|
||||
name="sigNozApiUrl"
|
||||
name="signoz_api_url"
|
||||
label="SigNoz API URL"
|
||||
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
|
||||
>
|
||||
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.sigNozApiKey && (
|
||||
{!connectionParams?.signoz_api_key && (
|
||||
<Form.Item
|
||||
name="sigNozApiKey"
|
||||
name="signoz_api_key"
|
||||
label="SigNoz API KEY"
|
||||
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
|
||||
>
|
||||
@@ -0,0 +1,162 @@
|
||||
.cloud-account-setup-success-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
text-align: center;
|
||||
padding-top: 34px;
|
||||
p,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
.cloud-account-setup-success-view {
|
||||
&__title {
|
||||
h3 {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
&__description {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__what-next {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
text-align: left;
|
||||
&-title {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.what-next-items-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
|
||||
&.ant-alert {
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
|
||||
&.ant-alert-info {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-background) 20%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.what-next-item {
|
||||
color: var(--bg-robin-400);
|
||||
&-bullet-icon {
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
&-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__footer {
|
||||
padding-top: 18px;
|
||||
.ant-btn {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.lottie-container {
|
||||
position: absolute;
|
||||
width: 743.5px;
|
||||
height: 990.342px;
|
||||
top: -100px;
|
||||
left: -36px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.cloud-account-setup-success-view {
|
||||
&__content {
|
||||
.cloud-account-setup-success-view {
|
||||
&__title {
|
||||
h3 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__what-next {
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.what-next-items-wrapper {
|
||||
&__item {
|
||||
&.ant-alert-info {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 20%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--primary-background) 10%,
|
||||
transparent
|
||||
);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.what-next-item {
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&-text {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.ant-btn {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import Lottie from 'react-lottie';
|
||||
import { Alert } from 'antd';
|
||||
import integrationsSuccess from 'assets/Lotties/integrations-success.json';
|
||||
|
||||
import solidCheckCircleUrl from '@/assets/Icons/solid-check-circle.svg';
|
||||
|
||||
import './SuccessView.style.scss';
|
||||
|
||||
export function SuccessView(): JSX.Element {
|
||||
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
|
||||
|
||||
const defaultOptions = {
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
animationData: integrationsSuccess,
|
||||
rendererSettings: {
|
||||
preserveAspectRatio: 'xMidYMid slice',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAnimationComplete && (
|
||||
<div className="lottie-container">
|
||||
<Lottie
|
||||
options={defaultOptions}
|
||||
height={990.342}
|
||||
width={743.5}
|
||||
eventListeners={[
|
||||
{
|
||||
eventName: 'complete',
|
||||
callback: (): void => setIsAnimationComplete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="cloud-account-setup-success-view">
|
||||
<div className="cloud-account-setup-success-view__icon">
|
||||
<img src={solidCheckCircleUrl} alt="Success" />
|
||||
</div>
|
||||
<div className="cloud-account-setup-success-view__content">
|
||||
<div className="cloud-account-setup-success-view__title">
|
||||
<h3>🎉 Success! </h3>
|
||||
<h3>Your AWS Web Service integration is all set.</h3>
|
||||
</div>
|
||||
<div className="cloud-account-setup-success-view__description">
|
||||
<p>Your observability journey is off to a great start. </p>
|
||||
<p>Now that your data is flowing, here’s what you can do next:</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-account-setup-success-view__what-next">
|
||||
<h4 className="cloud-account-setup-success-view__what-next-title">
|
||||
WHAT NEXT
|
||||
</h4>
|
||||
<div className="what-next-items-wrapper">
|
||||
<Alert
|
||||
message={
|
||||
<div className="what-next-items-wrapper__item">
|
||||
<div className="what-next-item-bullet-icon">•</div>
|
||||
<div className="what-next-item-text">
|
||||
Set up your AWS services effortlessly under your enabled account.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
className="what-next-items-wrapper__item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { FormInstance } from 'antd';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ConnectionParams } from 'types/api/integrations/aws';
|
||||
|
||||
export enum ActiveViewEnum {
|
||||
SELECT_REGIONS = 'select-regions',
|
||||
@@ -11,27 +11,23 @@ export enum ModalStateEnum {
|
||||
FORM = 'form',
|
||||
WAITING = 'waiting',
|
||||
ERROR = 'error',
|
||||
SUCCESS = 'success',
|
||||
}
|
||||
|
||||
export interface RegionFormProps {
|
||||
form: FormInstance;
|
||||
modalState: ModalStateEnum;
|
||||
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
|
||||
selectedRegions: string[];
|
||||
includeAllRegions: boolean;
|
||||
onIncludeAllRegionsChange: (checked: boolean) => void;
|
||||
onRegionSelect: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
accountId?: string;
|
||||
selectedDeploymentRegion: string | undefined;
|
||||
handleRegionChange: (value: string) => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
connectionParams?: ConnectionParams;
|
||||
isConnectionParamsLoading?: boolean;
|
||||
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
|
||||
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
|
||||
onConnectionSuccess: (payload: {
|
||||
cloudAccountId: string;
|
||||
status?: unknown;
|
||||
}) => void;
|
||||
onConnectionTimeout: (payload: { id?: string }) => void;
|
||||
onConnectionError: () => void;
|
||||
}
|
||||
|
||||
export interface IntegrationModalProps {
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { ServiceData } from './types';
|
||||
|
||||
function DashboardItem({
|
||||
dashboard,
|
||||
}: {
|
||||
dashboard: ServiceData['assets']['dashboards'][number];
|
||||
}): JSX.Element {
|
||||
const content = (
|
||||
<>
|
||||
<div className="cloud-service-dashboard-item__title">{dashboard.title}</div>
|
||||
<div className="cloud-service-dashboard-item__preview">
|
||||
<img
|
||||
src={dashboard.image}
|
||||
alt={dashboard.title}
|
||||
className="cloud-service-dashboard-item__preview-image"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cloud-service-dashboard-item">
|
||||
{dashboard.url ? (
|
||||
<Link to={dashboard.url} className="cloud-service-dashboard-item__link">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudServiceDashboards({
|
||||
service,
|
||||
}: {
|
||||
service: ServiceData;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{service.assets.dashboards.map((dashboard) => (
|
||||
<DashboardItem key={dashboard.id} dashboard={dashboard} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloudServiceDashboards;
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Table } from 'antd';
|
||||
import {
|
||||
CloudintegrationtypesCollectedLogAttributeDTO,
|
||||
CloudintegrationtypesCollectedMetricDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BarChart2, ScrollText } from 'lucide-react';
|
||||
|
||||
import './CloudServiceDataCollected.styles.scss';
|
||||
import { ServiceData } from './types';
|
||||
|
||||
function CloudServiceDataCollected({
|
||||
logsData,
|
||||
metricsData,
|
||||
}: {
|
||||
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
|
||||
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
|
||||
logsData: ServiceData['data_collected']['logs'];
|
||||
metricsData: ServiceData['data_collected']['metrics'];
|
||||
}): JSX.Element {
|
||||
const logsColumns = [
|
||||
{
|
||||
@@ -66,30 +61,24 @@ function CloudServiceDataCollected({
|
||||
return (
|
||||
<div className="cloud-service-data-collected">
|
||||
{logsData && logsData.length > 0 && (
|
||||
<div className="cloud-service-data-collected-table">
|
||||
<div className="cloud-service-data-collected-table-heading">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
<div className="cloud-service-data-collected__table">
|
||||
<div className="cloud-service-data-collected__table-heading">Logs</div>
|
||||
<Table
|
||||
columns={logsColumns}
|
||||
dataSource={logsData}
|
||||
{...tableProps}
|
||||
className="cloud-service-data-collected-table-logs"
|
||||
className="cloud-service-data-collected__table-logs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{metricsData && metricsData.length > 0 && (
|
||||
<div className="cloud-service-data-collected-table">
|
||||
<div className="cloud-service-data-collected-table-heading">
|
||||
<BarChart2 size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
<div className="cloud-service-data-collected__table">
|
||||
<div className="cloud-service-data-collected__table-heading">Metrics</div>
|
||||
<Table
|
||||
columns={metricsColumns}
|
||||
dataSource={metricsData}
|
||||
{...tableProps}
|
||||
className="cloud-service-data-collected-table-metrics"
|
||||
className="cloud-service-data-collected__table-metrics"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,89 @@
|
||||
.configure-service-modal {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 14px;
|
||||
|
||||
&-regions-switch-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&-label {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&-switch-description {
|
||||
margin-top: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
&-form-item {
|
||||
&:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-modal-body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ant-modal-footer {
|
||||
margin: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.configure-service-modal {
|
||||
&__body {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
&-regions-switch-switch {
|
||||
&-label {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&-switch-description {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
&.ant-btn-default {
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-primary {
|
||||
// Keep primary button same as dark mode
|
||||
background: var(--primary-background);
|
||||
color: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Form, Switch } from 'antd';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
ServiceConfig,
|
||||
SupportedSignals,
|
||||
} from 'container/CloudIntegrationPage/ServicesSection/types';
|
||||
import { useUpdateServiceConfig } from 'hooks/integration/aws/useUpdateServiceConfig';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import S3BucketsSelector from './S3BucketsSelector';
|
||||
|
||||
import './ConfigureServiceModal.styles.scss';
|
||||
|
||||
export interface IConfigureServiceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceName: string;
|
||||
serviceId: string;
|
||||
cloudAccountId: string;
|
||||
supportedSignals: SupportedSignals;
|
||||
initialConfig?: ServiceConfig;
|
||||
}
|
||||
|
||||
function ConfigureServiceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceName,
|
||||
serviceId,
|
||||
cloudAccountId,
|
||||
initialConfig,
|
||||
supportedSignals,
|
||||
}: IConfigureServiceModalProps): JSX.Element {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Track current form values
|
||||
const initialValues = useMemo(
|
||||
() => ({
|
||||
metrics: initialConfig?.metrics?.enabled || false,
|
||||
logs: initialConfig?.logs?.enabled || false,
|
||||
s3Buckets: initialConfig?.logs?.s3_buckets || {},
|
||||
}),
|
||||
[initialConfig],
|
||||
);
|
||||
const [currentValues, setCurrentValues] = useState(initialValues);
|
||||
|
||||
const isSaveDisabled = useMemo(
|
||||
() =>
|
||||
// disable only if current values are same as the initial config
|
||||
currentValues.metrics === initialValues.metrics &&
|
||||
currentValues.logs === initialValues.logs &&
|
||||
isEqual(currentValues.s3Buckets, initialValues.s3Buckets),
|
||||
[currentValues, initialValues],
|
||||
);
|
||||
|
||||
const handleS3BucketsChange = useCallback(
|
||||
(bucketsByRegion: Record<string, string[]>) => {
|
||||
setCurrentValues((prev) => ({
|
||||
...prev,
|
||||
s3Buckets: bucketsByRegion,
|
||||
}));
|
||||
form.setFieldsValue({ s3Buckets: bucketsByRegion });
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: updateServiceConfig,
|
||||
isLoading: isUpdating,
|
||||
} = useUpdateServiceConfig();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setIsLoading(true);
|
||||
|
||||
updateServiceConfig(
|
||||
{
|
||||
serviceId,
|
||||
payload: {
|
||||
cloud_account_id: cloudAccountId,
|
||||
config: {
|
||||
logs: {
|
||||
enabled: values.logs,
|
||||
s3_buckets: values.s3Buckets,
|
||||
},
|
||||
metrics: {
|
||||
enabled: values.metrics,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.AWS_SERVICE_DETAILS,
|
||||
serviceId,
|
||||
]);
|
||||
onClose();
|
||||
|
||||
logEvent('AWS Integration: Service settings saved', {
|
||||
cloudAccountId,
|
||||
serviceId,
|
||||
logsEnabled: values?.logs,
|
||||
metricsEnabled: values?.metrics,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update service config:', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Form submission failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
form,
|
||||
updateServiceConfig,
|
||||
serviceId,
|
||||
cloudAccountId,
|
||||
queryClient,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
title={
|
||||
<div className="account-settings-modal__title">Configure {serviceName}</div>
|
||||
}
|
||||
centered
|
||||
open={isOpen}
|
||||
okText="Save"
|
||||
okButtonProps={{
|
||||
disabled: isSaveDisabled,
|
||||
className: 'account-settings-modal__footer-save-button',
|
||||
loading: isLoading || isUpdating,
|
||||
}}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSubmit}
|
||||
cancelText="Close"
|
||||
cancelButtonProps={{
|
||||
className: 'account-settings-modal__footer-close-button',
|
||||
}}
|
||||
width={672}
|
||||
rootClassName=" configure-service-modal"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
metrics: initialConfig?.metrics?.enabled || false,
|
||||
logs: initialConfig?.logs?.enabled || false,
|
||||
s3Buckets: initialConfig?.logs?.s3_buckets || {},
|
||||
}}
|
||||
>
|
||||
<div className=" configure-service-modal__body">
|
||||
{supportedSignals.metrics && (
|
||||
<Form.Item
|
||||
name="metrics"
|
||||
valuePropName="checked"
|
||||
className="configure-service-modal__body-form-item"
|
||||
>
|
||||
<div className="configure-service-modal__body-regions-switch-switch">
|
||||
<Switch
|
||||
checked={currentValues.metrics}
|
||||
onChange={(checked): void => {
|
||||
setCurrentValues((prev) => ({ ...prev, metrics: checked }));
|
||||
form.setFieldsValue({ metrics: checked });
|
||||
}}
|
||||
/>
|
||||
<span className="configure-service-modal__body-regions-switch-switch-label">
|
||||
Metric Collection
|
||||
</span>
|
||||
</div>
|
||||
<div className="configure-service-modal__body-switch-description">
|
||||
Metric Collection is enabled for this AWS account. We recommend keeping
|
||||
this enabled, but you can disable metric collection if you do not want
|
||||
to monitor your AWS infrastructure.
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{supportedSignals.logs && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="logs"
|
||||
valuePropName="checked"
|
||||
className="configure-service-modal__body-form-item"
|
||||
>
|
||||
<div className="configure-service-modal__body-regions-switch-switch">
|
||||
<Switch
|
||||
checked={currentValues.logs}
|
||||
onChange={(checked): void => {
|
||||
setCurrentValues((prev) => ({ ...prev, logs: checked }));
|
||||
form.setFieldsValue({ logs: checked });
|
||||
}}
|
||||
/>
|
||||
<span className="configure-service-modal__body-regions-switch-switch-label">
|
||||
Log Collection
|
||||
</span>
|
||||
</div>
|
||||
<div className="configure-service-modal__body-switch-description">
|
||||
To ingest logs from your AWS services, you must complete several steps
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{currentValues.logs && serviceId === 's3sync' && (
|
||||
<Form.Item name="s3Buckets" noStyle>
|
||||
<S3BucketsSelector
|
||||
initialBucketsByRegion={currentValues.s3Buckets}
|
||||
onChange={handleS3BucketsChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
ConfigureServiceModal.defaultProps = {
|
||||
initialConfig: {
|
||||
metrics: { enabled: false },
|
||||
logs: { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export default ConfigureServiceModal;
|
||||
@@ -1,18 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select, Skeleton } from 'antd';
|
||||
import { useListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Form, Select, Skeleton, Typography } from 'antd';
|
||||
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { mapAccountDtoToAwsCloudAccount } from '../mapAwsCloudAccountFromDto';
|
||||
import { CloudAccount } from '../types';
|
||||
|
||||
import './S3BucketsSelector.styles.scss';
|
||||
const { Title } = Typography;
|
||||
|
||||
interface S3BucketsSelectorProps {
|
||||
onChange?: (bucketsByRegion: Record<string, string[]>) => void;
|
||||
initialBucketsByRegion?: Record<string, string[]>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,29 +17,13 @@ interface S3BucketsSelectorProps {
|
||||
function S3BucketsSelector({
|
||||
onChange,
|
||||
initialBucketsByRegion = {},
|
||||
disabled: isSelectorDisabled = false,
|
||||
}: S3BucketsSelectorProps): JSX.Element {
|
||||
const cloudAccountId = useUrlQuery().get('cloudAccountId');
|
||||
const { data: listAccountsResponse, isLoading } = useListAccounts({
|
||||
cloudProvider: INTEGRATION_TYPES.AWS,
|
||||
});
|
||||
const accounts = useMemo((): CloudAccount[] | undefined => {
|
||||
const raw = listAccountsResponse?.data?.accounts;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw
|
||||
.map(mapAccountDtoToAwsCloudAccount)
|
||||
.filter((account): account is CloudAccount => account !== null);
|
||||
}, [listAccountsResponse]);
|
||||
const { data: accounts, isLoading } = useAwsAccounts();
|
||||
const [bucketsByRegion, setBucketsByRegion] = useState<
|
||||
Record<string, string[]>
|
||||
>(initialBucketsByRegion);
|
||||
|
||||
useEffect(() => {
|
||||
setBucketsByRegion(initialBucketsByRegion);
|
||||
}, [initialBucketsByRegion]);
|
||||
|
||||
// Find the active AWS account based on the URL query parameter
|
||||
const activeAccount = useMemo(
|
||||
() =>
|
||||
@@ -102,41 +81,37 @@ function S3BucketsSelector({
|
||||
|
||||
return (
|
||||
<div className="s3-buckets-selector">
|
||||
<div className="s3-buckets-selector-title">Select S3 Buckets by Region</div>
|
||||
<div className="s3-buckets-selector-content">
|
||||
{allRegions.map((region) => {
|
||||
const isRegionUnavailable = isRegionDisabled(region);
|
||||
<Title level={5}>Select S3 Buckets by Region</Title>
|
||||
|
||||
return (
|
||||
<div key={region} className="s3-buckets-selector-region">
|
||||
<div className="s3-buckets-selector-region-header">
|
||||
<div className="s3-buckets-selector-region-label">{region}</div>
|
||||
{isRegionUnavailable && (
|
||||
<div className="s3-buckets-selector-region-help">
|
||||
Region disabled in account settings; S3 buckets here will not be
|
||||
synced.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="s3-buckets-selector-region-select">
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={`Enter S3 bucket names for ${region}`}
|
||||
value={bucketsByRegion[region] || []}
|
||||
onChange={(value): void => handleRegionBucketsChange(region, value)}
|
||||
tokenSeparators={[',']}
|
||||
allowClear
|
||||
disabled={isSelectorDisabled || isRegionUnavailable}
|
||||
suffixIcon={null}
|
||||
notFoundContent={null}
|
||||
filterOption={false}
|
||||
showSearch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allRegions.map((region) => {
|
||||
const disabled = isRegionDisabled(region);
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={region}
|
||||
label={region}
|
||||
{...(disabled && {
|
||||
help:
|
||||
'Region disabled in account settings; S3 buckets here will not be synced.',
|
||||
validateStatus: 'warning',
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={`Enter S3 bucket names for ${region}`}
|
||||
value={bucketsByRegion[region] || []}
|
||||
onChange={(value): void => handleRegionBucketsChange(region, value)}
|
||||
tokenSeparators={[',']}
|
||||
allowClear
|
||||
disabled={disabled}
|
||||
suffixIcon={null}
|
||||
notFoundContent={null}
|
||||
filterOption={false}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Tabs, TabsProps } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
import Spinner from 'components/Spinner';
|
||||
import CloudServiceDashboards from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDashboards';
|
||||
import CloudServiceDataCollected from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDataCollected';
|
||||
import { IServiceStatus } from 'container/CloudIntegrationPage/ServicesSection/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { useServiceDetails } from 'hooks/integration/aws/useServiceDetails';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import ConfigureServiceModal from './ConfigureServiceModal';
|
||||
|
||||
const getStatus = (
|
||||
logsLastReceivedTimestamp: number | undefined,
|
||||
metricsLastReceivedTimestamp: number | undefined,
|
||||
): { text: string; className: string } => {
|
||||
if (!logsLastReceivedTimestamp && !metricsLastReceivedTimestamp) {
|
||||
return { text: 'No Data Yet', className: 'service-status--no-data' };
|
||||
}
|
||||
|
||||
const latestTimestamp = Math.max(
|
||||
logsLastReceivedTimestamp || 0,
|
||||
metricsLastReceivedTimestamp || 0,
|
||||
);
|
||||
|
||||
const isStale = dayjs().diff(dayjs(latestTimestamp), 'minute') > 30;
|
||||
|
||||
if (isStale) {
|
||||
return { text: 'Stale Data', className: 'service-status--stale-data' };
|
||||
}
|
||||
|
||||
return { text: 'Connected', className: 'service-status--connected' };
|
||||
};
|
||||
|
||||
function ServiceStatus({
|
||||
serviceStatus,
|
||||
}: {
|
||||
serviceStatus: IServiceStatus | undefined;
|
||||
}): JSX.Element {
|
||||
const logsLastReceivedTimestamp = serviceStatus?.logs?.last_received_ts_ms;
|
||||
const metricsLastReceivedTimestamp =
|
||||
serviceStatus?.metrics?.last_received_ts_ms;
|
||||
|
||||
const { text, className } = getStatus(
|
||||
logsLastReceivedTimestamp,
|
||||
metricsLastReceivedTimestamp,
|
||||
);
|
||||
|
||||
return <div className={`service-status ${className}`}>{text}</div>;
|
||||
}
|
||||
|
||||
function getTabItems(serviceDetailsData: any): TabsProps['items'] {
|
||||
const dashboards = serviceDetailsData?.assets.dashboards || [];
|
||||
const dataCollected = serviceDetailsData?.data_collected || {};
|
||||
const items: TabsProps['items'] = [];
|
||||
|
||||
if (dashboards.length) {
|
||||
items.push({
|
||||
key: 'dashboards',
|
||||
label: `Dashboards (${dashboards.length})`,
|
||||
children: <CloudServiceDashboards service={serviceDetailsData} />,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'data-collected',
|
||||
label: 'Data Collected',
|
||||
children: (
|
||||
<CloudServiceDataCollected
|
||||
logsData={dataCollected.logs || []}
|
||||
metricsData={dataCollected.metrics || []}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function ServiceDetails(): JSX.Element | null {
|
||||
const urlQuery = useUrlQuery();
|
||||
const cloudAccountId = urlQuery.get('cloudAccountId');
|
||||
const serviceId = urlQuery.get('service');
|
||||
const [isConfigureServiceModalOpen, setIsConfigureServiceModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
const openServiceConfigModal = (): void => {
|
||||
setIsConfigureServiceModalOpen(true);
|
||||
logEvent('AWS Integration: Service settings viewed', {
|
||||
cloudAccountId,
|
||||
serviceId,
|
||||
});
|
||||
};
|
||||
|
||||
const { data: serviceDetailsData, isLoading } = useServiceDetails(
|
||||
serviceId || '',
|
||||
cloudAccountId || undefined,
|
||||
);
|
||||
|
||||
const { config, supported_signals } = serviceDetailsData ?? {};
|
||||
|
||||
const totalSupportedSignals = Object.entries(supported_signals || {}).filter(
|
||||
([, value]) => !!value,
|
||||
).length;
|
||||
const enabledSignals = useMemo(
|
||||
() =>
|
||||
Object.values(config || {}).filter((item) => item && item.enabled).length,
|
||||
[config],
|
||||
);
|
||||
|
||||
const isAnySignalConfigured = useMemo(
|
||||
() => !!config?.logs?.enabled || !!config?.metrics?.enabled,
|
||||
[config],
|
||||
);
|
||||
|
||||
// log telemetry event on visiting details of a service.
|
||||
useEffect(() => {
|
||||
if (serviceId) {
|
||||
logEvent('AWS Integration: Service viewed', {
|
||||
cloudAccountId,
|
||||
serviceId,
|
||||
});
|
||||
}
|
||||
}, [cloudAccountId, serviceId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner size="large" height="50vh" />;
|
||||
}
|
||||
|
||||
if (!serviceDetailsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabItems = getTabItems(serviceDetailsData);
|
||||
|
||||
return (
|
||||
<div className="service-details">
|
||||
<div className="service-details__title-bar">
|
||||
<div className="service-details__details-title">Details</div>
|
||||
<div className="service-details__right-actions">
|
||||
{isAnySignalConfigured && (
|
||||
<ServiceStatus serviceStatus={serviceDetailsData.status} />
|
||||
)}
|
||||
|
||||
{!!cloudAccountId &&
|
||||
(isAnySignalConfigured ? (
|
||||
<Button
|
||||
className="configure-button configure-button--default"
|
||||
onClick={openServiceConfigModal}
|
||||
>
|
||||
Configure ({enabledSignals}/{totalSupportedSignals})
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
className="configure-button configure-button--primary"
|
||||
onClick={openServiceConfigModal}
|
||||
>
|
||||
Enable Service
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-details__overview">
|
||||
<MarkdownRenderer
|
||||
variables={{}}
|
||||
markdownContent={serviceDetailsData?.overview}
|
||||
/>
|
||||
</div>
|
||||
<div className="service-details__tabs">
|
||||
<Tabs items={tabItems} />
|
||||
</div>
|
||||
{isConfigureServiceModalOpen && (
|
||||
<ConfigureServiceModal
|
||||
isOpen
|
||||
onClose={(): void => setIsConfigureServiceModalOpen(false)}
|
||||
serviceName={serviceDetailsData.title}
|
||||
serviceId={serviceId || ''}
|
||||
cloudAccountId={cloudAccountId || ''}
|
||||
initialConfig={serviceDetailsData.config}
|
||||
supportedSignals={serviceDetailsData.supported_signals || {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceDetails;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useGetAccountServices } from 'hooks/integration/aws/useGetAccountServices';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import ServiceItem from './ServiceItem';
|
||||
|
||||
interface ServicesListProps {
|
||||
cloudAccountId: string;
|
||||
filter: 'all_services' | 'enabled' | 'available';
|
||||
}
|
||||
|
||||
function ServicesList({
|
||||
cloudAccountId,
|
||||
filter,
|
||||
}: ServicesListProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const navigate = useNavigate();
|
||||
const { data: services = [], isLoading } = useGetAccountServices(
|
||||
cloudAccountId,
|
||||
);
|
||||
const activeService = urlQuery.get('service');
|
||||
|
||||
const handleActiveService = useCallback(
|
||||
(serviceId: string): void => {
|
||||
const latestUrlQuery = new URLSearchParams(window.location.search);
|
||||
latestUrlQuery.set('service', serviceId);
|
||||
navigate({ search: latestUrlQuery.toString() });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (filter === 'all_services') {
|
||||
return services;
|
||||
}
|
||||
|
||||
return services.filter((service) => {
|
||||
const isEnabled =
|
||||
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
|
||||
return filter === 'enabled' ? isEnabled : !isEnabled;
|
||||
});
|
||||
}, [services, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeService || !services?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleActiveService(services[0].id);
|
||||
}, [services, activeService, handleActiveService]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner size="large" height="25vh" />;
|
||||
}
|
||||
if (!services) {
|
||||
return <div>No services found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="services-list">
|
||||
{filteredServices.map((service) => (
|
||||
<ServiceItem
|
||||
key={service.id}
|
||||
service={service}
|
||||
onClick={handleActiveService}
|
||||
isActive={service.id === activeService}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServicesList;
|
||||
@@ -1,8 +1,4 @@
|
||||
.services-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 54px); /* 54px is the height of the header */
|
||||
|
||||
.ant-tabs-tab {
|
||||
font-family: 'Inter';
|
||||
padding: 16px 4px 14px;
|
||||
@@ -22,60 +18,21 @@
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
.services-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
gap: 10px;
|
||||
&__sidebar {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
height: 100%;
|
||||
width: 16%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
width: 84%;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.service-details-loading,
|
||||
.services-list-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
|
||||
.service-details-loading-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
.services-list-empty-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
padding: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.empty-state-svg {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.services-filter {
|
||||
padding: 12px;
|
||||
|
||||
padding: 16px 0;
|
||||
.ant-select-selector {
|
||||
background-color: var(--l3-background) !important;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
@@ -89,111 +46,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aws-services-list-view {
|
||||
height: 100%;
|
||||
|
||||
.aws-services-list-view-sidebar {
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--l3-background);
|
||||
padding: 12px;
|
||||
|
||||
.aws-services-list-view-sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.aws-services-enabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aws-services-not-enabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aws-services-list-view-sidebar-content-header {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.aws-services-list-view-sidebar-content-item-empty-message {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
}
|
||||
|
||||
.aws-services-list-view-sidebar-content-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.aws-services-list-view-sidebar-content-item-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.aws-services-list-view-sidebar-content-item-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.aws-services-list-view-sidebar-content-item-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aws-services-list-view-main {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.service-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -208,22 +60,20 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--l3-background);
|
||||
background-color: var(--bg-ink-100); /* keep: no semantic equivalent */
|
||||
}
|
||||
&__icon-wrapper {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--l3-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
|
||||
.service-item__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
&__title {
|
||||
@@ -240,13 +90,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&__title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.service-details__details-title {
|
||||
@@ -257,7 +105,6 @@
|
||||
letter-spacing: -0.07px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.service-details__right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -273,30 +120,19 @@
|
||||
border-radius: 2px;
|
||||
line-height: normal;
|
||||
&--connected {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--success-background) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--success-background) 10%, transparent);
|
||||
color: var(--callout-success-title);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
&--stale-data {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--warning-background-hover) 10%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--warning-background-hover) 10%, transparent);
|
||||
color: var(--callout-warning-title);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
&--no-data {
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--danger-background-hover) 10%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--danger-background-hover) 10%,
|
||||
transparent
|
||||
);
|
||||
color: var(--callout-error-description);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,28 +157,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__overview {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
width: 100%;
|
||||
|
||||
padding: 8px 12px;
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
padding: 0px 12px 12px 8px;
|
||||
|
||||
.ant-tabs {
|
||||
&-ink-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
&-nav {
|
||||
padding: 0;
|
||||
|
||||
padding: 8px 0 18px;
|
||||
&-wrap {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -461,3 +290,153 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.services-tabs {
|
||||
.ant-tabs-tab {
|
||||
&.ant-tabs-tab-active {
|
||||
.ant-tabs-tab-btn {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.services-filter {
|
||||
.ant-select-selector {
|
||||
background-color: var(--l1-background) !important;
|
||||
border-color: var(--l1-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.service-item {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
background-color: var(--l1-background);
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.service-details {
|
||||
&__title-bar {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.service-details__details-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.configure-button {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l1-background);
|
||||
border-color: var(--l1-border);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.service-status {
|
||||
&--connected {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
|
||||
&--stale-data {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
&--no-data {
|
||||
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__overview {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
.ant-tabs {
|
||||
&-tab {
|
||||
&-btn {
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&[aria-selected='true'] {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
&-nav-list {
|
||||
border-color: var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-service {
|
||||
&-dashboard-item {
|
||||
&__title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&-data-collected {
|
||||
&__table {
|
||||
.ant-table {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr {
|
||||
&:nth-child(odd),
|
||||
&:hover > td {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
> td {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__table-heading {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { SelectProps, TabsProps } from 'antd';
|
||||
import { Select, Tabs } from 'antd';
|
||||
import { getAwsServices } from 'api/integration/aws';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import ServiceDetails from './ServiceDetails';
|
||||
import ServicesList from './ServicesList';
|
||||
|
||||
import './ServicesTabs.style.scss';
|
||||
|
||||
export enum ServiceFilterType {
|
||||
ALL_SERVICES = 'all_services',
|
||||
ENABLED = 'enabled',
|
||||
AVAILABLE = 'available',
|
||||
}
|
||||
|
||||
interface ServicesFilterProps {
|
||||
cloudAccountId: string;
|
||||
onFilterChange: (value: ServiceFilterType) => void;
|
||||
}
|
||||
|
||||
function ServicesFilter({
|
||||
cloudAccountId,
|
||||
onFilterChange,
|
||||
}: ServicesFilterProps): JSX.Element | null {
|
||||
const { data: services, isLoading } = useQuery(
|
||||
[REACT_QUERY_KEY.AWS_SERVICES, cloudAccountId],
|
||||
() => getAwsServices(cloudAccountId),
|
||||
);
|
||||
|
||||
const { enabledCount, availableCount } = useMemo(() => {
|
||||
if (!services) {
|
||||
return { enabledCount: 0, availableCount: 0 };
|
||||
}
|
||||
|
||||
return services.reduce(
|
||||
(acc, service) => {
|
||||
const isEnabled =
|
||||
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
|
||||
return {
|
||||
enabledCount: acc.enabledCount + (isEnabled ? 1 : 0),
|
||||
availableCount: acc.availableCount + (isEnabled ? 0 : 1),
|
||||
};
|
||||
},
|
||||
{ enabledCount: 0, availableCount: 0 },
|
||||
);
|
||||
}, [services]);
|
||||
|
||||
const selectOptions: SelectProps['options'] = useMemo(
|
||||
() => [
|
||||
{ value: 'all_services', label: `All Services (${services?.length || 0})` },
|
||||
{ value: 'enabled', label: `Enabled (${enabledCount})` },
|
||||
{ value: 'available', label: `Available (${availableCount})` },
|
||||
],
|
||||
[services, enabledCount, availableCount],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
if (!services?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="services-filter">
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
defaultValue={ServiceFilterType.ALL_SERVICES}
|
||||
options={selectOptions}
|
||||
className="services-sidebar__select"
|
||||
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesSection(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState<
|
||||
'all_services' | 'enabled' | 'available'
|
||||
>('all_services');
|
||||
|
||||
return (
|
||||
<div className="services-section">
|
||||
<div className="services-section__sidebar">
|
||||
<ServicesFilter
|
||||
cloudAccountId={cloudAccountId}
|
||||
onFilterChange={setActiveFilter}
|
||||
/>
|
||||
<ServicesList cloudAccountId={cloudAccountId} filter={activeFilter} />
|
||||
</div>
|
||||
<div className="services-section__content">
|
||||
<ServiceDetails />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTabs(): JSX.Element {
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'services',
|
||||
label: 'Services For Integration',
|
||||
children: <ServicesSection />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="services-tabs">
|
||||
<Tabs defaultActiveKey="services" items={tabItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServicesTabs;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw'; // Import RestRequest for req.json() typing
|
||||
|
||||
import { UpdateServiceConfigPayload } from '../types';
|
||||
import { accountsResponse, CLOUD_ACCOUNT_ID, initialBuckets } from './mockData';
|
||||
import {
|
||||
assertGenericModalElements,
|
||||
assertS3SyncSpecificElements,
|
||||
renderModal,
|
||||
} from './utils';
|
||||
|
||||
// --- MOCKS ---
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
get: jest.fn((paramName: string) => {
|
||||
if (paramName === 'cloudAccountId') {
|
||||
return CLOUD_ACCOUNT_ID;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- TEST SUITE ---
|
||||
describe('ConfigureServiceModal for S3 Sync service', () => {
|
||||
jest.setTimeout(10000);
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud-integrations/aws/accounts',
|
||||
(req, res, ctx) => res(ctx.json(accountsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with logs collection switch and bucket selectors (no buckets initially selected)', async () => {
|
||||
act(() => {
|
||||
renderModal({}); // No initial S3 buckets, defaults to 's3sync' serviceId
|
||||
});
|
||||
await assertGenericModalElements(); // Use new generic assertion
|
||||
await assertS3SyncSpecificElements({}); // Use new S3-specific assertion
|
||||
});
|
||||
|
||||
it('should render with logs collection switch and bucket selectors (some buckets initially selected)', async () => {
|
||||
act(() => {
|
||||
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
|
||||
});
|
||||
await assertGenericModalElements(); // Use new generic assertion
|
||||
await assertS3SyncSpecificElements(initialBuckets); // Use new S3-specific assertion
|
||||
});
|
||||
|
||||
it('should enable save button after adding a new bucket via combobox', async () => {
|
||||
act(() => {
|
||||
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
|
||||
});
|
||||
await assertGenericModalElements();
|
||||
await assertS3SyncSpecificElements(initialBuckets);
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
|
||||
const targetCombobox = screen.getAllByRole('combobox')[0];
|
||||
const newBucketName = 'a-newly-added-bucket';
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
|
||||
fireEvent.keyDown(targetCombobox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should send updated bucket configuration on save', async () => {
|
||||
let capturedPayload: UpdateServiceConfigPayload | null = null;
|
||||
const mockUpdateConfigUrl =
|
||||
'http://localhost/api/v1/cloud-integrations/aws/services/s3sync/config';
|
||||
|
||||
// Override POST handler specifically for this test to capture payload
|
||||
server.use(
|
||||
rest.post(mockUpdateConfigUrl, async (req: RestRequest, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ message: 'Config updated' }));
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
|
||||
});
|
||||
await assertGenericModalElements();
|
||||
await assertS3SyncSpecificElements(initialBuckets);
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
|
||||
const newBucketName = 'another-new-bucket';
|
||||
// As before, targeting the first combobox, assumed to be for 'ap-south-1'.
|
||||
const targetCombobox = screen.getAllByRole('combobox')[0];
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
|
||||
fireEvent.keyDown(targetCombobox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayload).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(capturedPayload).toEqual({
|
||||
cloud_account_id: CLOUD_ACCOUNT_ID,
|
||||
config: {
|
||||
logs: {
|
||||
enabled: true,
|
||||
s3_buckets: {
|
||||
'us-east-2': ['first-bucket', 'second-bucket'], // Existing buckets
|
||||
'ap-south-1': [newBucketName], // Newly added bucket for the first region
|
||||
},
|
||||
},
|
||||
metrics: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render S3 bucket region selector UI for services other than s3sync', async () => {
|
||||
const otherServiceId = 'cloudwatch';
|
||||
act(() => {
|
||||
renderModal({}, otherServiceId);
|
||||
});
|
||||
await assertGenericModalElements();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: /select s3 buckets by region/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
|
||||
regions.forEach((region) => {
|
||||
expect(
|
||||
screen.queryByText(`Enter S3 bucket names for ${region}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IConfigureServiceModalProps } from '../ConfigureServiceModal';
|
||||
|
||||
const CLOUD_ACCOUNT_ID = '123456789012';
|
||||
|
||||
const initialBuckets = { 'us-east-2': ['first-bucket', 'second-bucket'] };
|
||||
|
||||
const accountsResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
|
||||
cloud_account_id: CLOUD_ACCOUNT_ID,
|
||||
config: {
|
||||
regions: ['ap-south-1', 'ap-south-2', 'us-east-1', 'us-east-2'],
|
||||
},
|
||||
status: {
|
||||
integration: {
|
||||
last_heartbeat_ts_ms: 1747114366214,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const defaultModalProps: Omit<IConfigureServiceModalProps, 'initialConfig'> = {
|
||||
isOpen: true,
|
||||
onClose: jest.fn(),
|
||||
serviceName: 'S3 Sync',
|
||||
serviceId: 's3sync',
|
||||
cloudAccountId: CLOUD_ACCOUNT_ID,
|
||||
supportedSignals: {
|
||||
logs: true,
|
||||
metrics: false,
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
accountsResponse,
|
||||
CLOUD_ACCOUNT_ID,
|
||||
defaultModalProps,
|
||||
initialBuckets,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
|
||||
import ConfigureServiceModal from '../ConfigureServiceModal';
|
||||
import { accountsResponse, defaultModalProps } from './mockData';
|
||||
|
||||
/**
|
||||
* Renders the ConfigureServiceModal with specified S3 bucket initial configurations.
|
||||
*/
|
||||
const renderModal = (
|
||||
initialConfigLogsS3Buckets: Record<string, string[]> = {},
|
||||
serviceId = 's3sync',
|
||||
): RenderResult => {
|
||||
const initialConfig = {
|
||||
logs: { enabled: true, s3_buckets: initialConfigLogsS3Buckets },
|
||||
metrics: { enabled: false },
|
||||
};
|
||||
|
||||
return render(
|
||||
<MockQueryClientProvider>
|
||||
<ConfigureServiceModal
|
||||
{...defaultModalProps}
|
||||
serviceId={serviceId}
|
||||
initialConfig={initialConfig}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that generic UI elements of the modal are present.
|
||||
*/
|
||||
const assertGenericModalElements = async (): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
expect(screen.getByText(/log collection/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/to ingest logs from your aws services, you must complete several steps/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts the state of S3 bucket selectors for each region, specific to S3 Sync.
|
||||
*/
|
||||
const assertS3SyncSpecificElements = async (
|
||||
expectedBucketsByRegion: Record<string, string[]> = {},
|
||||
): Promise<void> => {
|
||||
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /select s3 buckets by region/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
regions.forEach((region) => {
|
||||
expect(screen.getByText(region)).toBeInTheDocument();
|
||||
const bucketsForRegion = expectedBucketsByRegion[region] || [];
|
||||
if (bucketsForRegion.length > 0) {
|
||||
bucketsForRegion.forEach((bucket) => {
|
||||
expect(screen.getByText(bucket)).toBeInTheDocument();
|
||||
});
|
||||
} else {
|
||||
expect(
|
||||
screen.getByText(`Enter S3 bucket names for ${region}`),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
assertGenericModalElements,
|
||||
assertS3SyncSpecificElements,
|
||||
renderModal,
|
||||
};
|
||||
@@ -1,40 +1,90 @@
|
||||
import { ServiceData } from 'container/Integrations/types';
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
config: AWSServiceConfig;
|
||||
config: ServiceConfig;
|
||||
}
|
||||
|
||||
interface S3BucketsByRegion {
|
||||
[region: string]: string[];
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface LogField {
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface ConfigStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface DataStatus {
|
||||
last_received_ts_ms: number;
|
||||
last_received_from: string;
|
||||
}
|
||||
|
||||
interface S3BucketsByRegion {
|
||||
[region: string]: string[];
|
||||
}
|
||||
|
||||
interface LogsConfig extends ConfigStatus {
|
||||
s3_buckets?: S3BucketsByRegion;
|
||||
}
|
||||
|
||||
interface AWSServiceConfig {
|
||||
interface ServiceConfig {
|
||||
logs: LogsConfig;
|
||||
metrics: ConfigStatus;
|
||||
s3_sync?: LogsConfig;
|
||||
}
|
||||
|
||||
interface IServiceStatus {
|
||||
logs: DataStatus | null;
|
||||
metrics: DataStatus | null;
|
||||
}
|
||||
|
||||
interface SupportedSignals {
|
||||
metrics: boolean;
|
||||
logs: boolean;
|
||||
}
|
||||
|
||||
interface ServiceData {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
overview: string;
|
||||
supported_signals: SupportedSignals;
|
||||
assets: {
|
||||
dashboards: Dashboard[];
|
||||
};
|
||||
data_collected: {
|
||||
logs?: LogField[];
|
||||
metrics: Metric[];
|
||||
};
|
||||
config?: ServiceConfig;
|
||||
status?: IServiceStatus;
|
||||
}
|
||||
|
||||
interface ServiceDetailsResponse {
|
||||
status: 'success';
|
||||
data: ServiceData;
|
||||
}
|
||||
|
||||
export interface AWSCloudAccountConfig {
|
||||
interface CloudAccountConfig {
|
||||
regions: string[];
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
interface IntegrationStatus {
|
||||
last_heartbeat_ts_ms: number;
|
||||
}
|
||||
|
||||
@@ -45,9 +95,8 @@ interface AccountStatus {
|
||||
interface CloudAccount {
|
||||
id: string;
|
||||
cloud_account_id: string;
|
||||
config: AWSCloudAccountConfig;
|
||||
config: CloudAccountConfig;
|
||||
status: AccountStatus;
|
||||
providerAccountId: string;
|
||||
}
|
||||
|
||||
interface CloudAccountsData {
|
||||
@@ -84,13 +133,15 @@ interface UpdateServiceConfigResponse {
|
||||
}
|
||||
|
||||
export type {
|
||||
AWSServiceConfig,
|
||||
CloudAccount,
|
||||
CloudAccountsData,
|
||||
IServiceStatus,
|
||||
S3BucketsByRegion,
|
||||
Service,
|
||||
ServiceConfig,
|
||||
ServiceData,
|
||||
ServiceDetailsResponse,
|
||||
SupportedSignals,
|
||||
UpdateServiceConfigPayload,
|
||||
UpdateServiceConfigResponse,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
IntegrationType,
|
||||
RequestIntegrationBtn,
|
||||
} from 'pages/Integrations/RequestIntegrationBtn';
|
||||
import i18n from 'ReactI18';
|
||||
|
||||
describe('Request AWS integration', () => {
|
||||
it('should render the request integration button', async () => {
|
||||
let capturedPayload: any;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/event', async (req, res, ctx) => {
|
||||
capturedPayload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: 'Event Processed Successfully',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<RequestIntegrationBtn type={IntegrationType.AWS_SERVICES} />{' '}
|
||||
</I18nextProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/can't find what you’re looking for\? request more integrations/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Enter integration name/i), {
|
||||
target: { value: 's3 sync' },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
|
||||
expect(submitButton).toBeEnabled();
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(capturedPayload.eventName).toBeDefined();
|
||||
expect(capturedPayload.attributes).toBeDefined();
|
||||
|
||||
expect(capturedPayload.eventName).toBe('AWS service integration requested');
|
||||
expect(capturedPayload.attributes).toEqual({
|
||||
screen: 'AWS integration details',
|
||||
integration: 's3 sync',
|
||||
deployment_url: 'localhost',
|
||||
user_email: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ROUTING_POLICIES_ROUTE } from './constants';
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
@@ -388,7 +387,7 @@ export function NotificationChannelsNotFoundContent({
|
||||
style={{ padding: '0 4px' }}
|
||||
type="link"
|
||||
onClick={(): void => {
|
||||
openInNewTab(ROUTES.CHANNELS_NEW);
|
||||
window.open(ROUTES.CHANNELS_NEW, '_blank');
|
||||
}}
|
||||
>
|
||||
here.
|
||||
|
||||
@@ -15,7 +15,6 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ChannelSelect from './ChannelSelect';
|
||||
@@ -88,7 +87,7 @@ function BasicInfo({
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
ruleId: isNewRule ? 0 : alertDef?.id,
|
||||
});
|
||||
openInNewTab(ROUTES.CHANNELS_NEW);
|
||||
window.open(ROUTES.CHANNELS_NEW, '_blank');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
@@ -10,7 +10,6 @@ import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
@@ -115,7 +114,7 @@ export default function Dashboards({
|
||||
dashboardName: dashboard.data.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
window.open(getLink(), '_blank');
|
||||
} else {
|
||||
safeNavigate(getLink());
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { VerticalAlignTopOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFilters,
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
fetchHostEntityData,
|
||||
fetchHostListData,
|
||||
getHostMetricsQueryPayload,
|
||||
hostDetailsMetadataConfig,
|
||||
hostGetEntityName,
|
||||
hostGetSelectedItemFilters,
|
||||
hostInitialEventsFilter,
|
||||
hostInitialLogTracesFilter,
|
||||
hostWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
hostColumns,
|
||||
hostColumnsConfig,
|
||||
hostRenderRowData,
|
||||
} from './table.config';
|
||||
import { getHostsQuickFiltersConfig } from './utils';
|
||||
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
// Track previous urlFilters to only sync when the value actually changes
|
||||
// (not when handleChangeQueryData changes due to query updates)
|
||||
const prevUrlFiltersRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentFiltersJson = urlFilters ? JSON.stringify(urlFilters) : null;
|
||||
|
||||
// Only sync if urlFilters value has actually changed
|
||||
if (prevUrlFiltersRef.current !== currentFiltersJson) {
|
||||
prevUrlFiltersRef.current = currentFiltersJson;
|
||||
// Sync filters to query builder, using empty filter when urlFilters is null
|
||||
handleChangeQueryData('filters', urlFilters || { items: [], op: 'and' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlFilters]); // handleChangeQueryData intentionally omitted - we call the current version but don't re-run when it changes
|
||||
|
||||
const handleFilterVisibilityChange = (): void => {
|
||||
setShowFilters(!showFilters);
|
||||
};
|
||||
|
||||
const handleQuickFiltersChange = (query: Query): void => {
|
||||
const filters = query.builder.queryData[0].filters;
|
||||
// Nuqs batches these calls into a single URL update
|
||||
// The useEffect will sync filters to query builder
|
||||
setUrlFilters(filters || null);
|
||||
setCurrentPage(1);
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchListData = useCallback(
|
||||
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
|
||||
filters.orderBy ||= {
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
};
|
||||
|
||||
return fetchHostListData(filters, signal);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchEntityData = useCallback(
|
||||
async (
|
||||
filters: Parameters<typeof fetchHostEntityData>[0],
|
||||
signal?: AbortSignal,
|
||||
) => fetchHostEntityData(filters, signal),
|
||||
[],
|
||||
);
|
||||
|
||||
const getSelectedItemFilters = useCallback(
|
||||
(selectedItem: string) =>
|
||||
hostGetSelectedItemFilters(selectedItem, dotMetricsEnabled),
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const getInitialLogTracesFilters = useCallback(
|
||||
(host: import('api/infraMonitoring/getHostLists').HostData) =>
|
||||
hostInitialLogTracesFilter(host, dotMetricsEnabled),
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const primaryFilterKeys = useMemo(
|
||||
() => [dotMetricsEnabled ? 'host.name' : 'host_name'],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const controlListPrefix = !showFilters ? (
|
||||
<div className={styles.quickFiltersToggleContainer}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
>
|
||||
<Filter size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
<VerticalAlignTopOutlined
|
||||
rotate={270}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
showFilters ? ` ${styles.listContainerFiltersVisible}` : ''
|
||||
}`}
|
||||
>
|
||||
<K8sBaseList
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
tableColumnsDefinitions={hostColumns}
|
||||
tableColumns={hostColumnsConfig}
|
||||
fetchListData={fetchListData}
|
||||
renderRowData={hostRenderRowData}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<K8sBaseDetails
|
||||
category={InfraMonitoringEntity.HOSTS}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
getSelectedItemFilters={getSelectedItemFilters}
|
||||
fetchEntityData={fetchEntityData}
|
||||
getEntityName={hostGetEntityName}
|
||||
getInitialLogTracesFilters={getInitialLogTracesFilters}
|
||||
getInitialEventsFilters={hostInitialEventsFilter}
|
||||
primaryFilterKeys={primaryFilterKeys}
|
||||
metadataConfig={hostDetailsMetadataConfig}
|
||||
entityWidgetInfo={hostWidgetInfo}
|
||||
getEntityQueryPayload={getHostMetricsQueryPayload}
|
||||
queryKeyPrefix="hosts"
|
||||
tabsConfig={{
|
||||
showEvents: false,
|
||||
showContainers: true,
|
||||
showProcesses: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Hosts;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
|
||||
|
||||
export default function HostsEmptyOrIncorrectMetrics({
|
||||
noData,
|
||||
incorrectData,
|
||||
}: {
|
||||
noData: boolean;
|
||||
incorrectData: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="hosts-empty-state-container">
|
||||
<div className="hosts-empty-state-container-content">
|
||||
<img className="eyes-emoji" src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
|
||||
{noData && (
|
||||
<div className="no-hosts-message">
|
||||
<Typography.Title level={5} className="no-hosts-message-title">
|
||||
No host metrics data received yet.
|
||||
</Typography.Title>
|
||||
|
||||
<Typography.Text className="no-hosts-message-text">
|
||||
Infrastructure monitoring requires the{' '}
|
||||
<a
|
||||
href="https://github.com/open-telemetry/semantic-conventions/blob/main/docs/system/system-metrics.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
OpenTelemetry system metrics
|
||||
</a>
|
||||
. Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/hostmetrics"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
this
|
||||
</a>{' '}
|
||||
to learn how to send host metrics to SigNoz.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{incorrectData && (
|
||||
<Typography.Text className="incorrect-metrics-message">
|
||||
To see host metrics, upgrade to the latest version of SigNoz k8s-infra
|
||||
chart. Please contact support if you need help.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user