Compare commits

..

6 Commits

402 changed files with 29310 additions and 22816 deletions

64
.github/CODEOWNERS vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
() =>

View File

@@ -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',
},

View 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;

View File

@@ -4774,7 +4774,7 @@ export interface RuletypesPostableRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4899,7 +4899,7 @@ export interface RuletypesRuleDTO {
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
alertType?: RuletypesAlertTypeDTO;
/**
* @type object
*/
@@ -4984,8 +4984,8 @@ export interface RuletypesRuleConditionDTO {
*/
algorithm?: string;
compositeQuery: RuletypesAlertCompositeQueryDTO;
matchType?: RuletypesMatchTypeDTO;
op?: RuletypesCompareOperatorDTO;
matchType: RuletypesMatchTypeDTO;
op: RuletypesCompareOperatorDTO;
/**
* @type boolean
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
};

View File

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

View File

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

View File

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

View 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;
};

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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');

View File

@@ -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(),
}));

View File

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

View File

@@ -0,0 +1,7 @@
import { HostData } from 'api/infraMonitoring/getHostLists';
export type HostDetailProps = {
host: HostData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

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

View File

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

View File

@@ -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,
},
],
},
});

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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,
});

View File

@@ -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%);
}
}

View 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;

View File

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

View 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,
};

View File

@@ -0,0 +1,3 @@
import HostMetricsDetails from './HostMetricsDetails';
export default HostMetricsDetails;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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}
/>
)}

View File

@@ -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);
}
}
}
}
}

View File

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

View File

@@ -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);
}
}
}
}
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

@@ -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&apos;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;

View File

@@ -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' }]}
>

View File

@@ -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);
}
}
}
}
}

View File

@@ -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, heres 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>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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);
}
}
}
}
}

View File

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

View File

@@ -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();
});
});
});
});

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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 youre 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,
});
});
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -30,7 +30,6 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -63,13 +62,6 @@ export default function ChartWrapper({
[customTooltip],
);
const syncMetadata = useMemo(
() => ({
yAxisUnit,
}),
[yAxisUnit],
);
return (
<PlotContextProvider>
<ChartLayout
@@ -107,7 +99,6 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -24,12 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -12,7 +12,10 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -29,31 +32,18 @@ interface UPlotBasedChartProps {
layoutChildren?: React.ReactNode;
}
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
timezone?: Timezone;
}
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
UPlotBasedChartProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =

View File

@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
>
<ContextMenu

View File

@@ -3,6 +3,8 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -27,6 +29,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const config = useMemo(() => {
return prepareHistogramPanelConfig({
@@ -89,9 +92,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -48,8 +48,8 @@ jest.mock(
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props?.yAxisUnit,
decimalPrecision: props?.decimalPrecision,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
})}
</div>
{props.layoutChildren}

View File

@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

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

View File

@@ -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>
);
}

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import HostMetricDetail from 'components/HostMetricsDetail';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersHosts,
useInfraMonitoringOrderByHosts,
} from 'container/InfraMonitoringK8s/hooks';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Filter } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import HostsListControls from './HostsListControls';
import HostsListTable from './HostsListTable';
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
import './InfraMonitoring.styles.scss';
const defaultFilters: TagFilter = { items: [], op: 'and' };
const baseQuery = getHostListsQuery();
function HostsList(): JSX.Element {
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filters, setFilters] = useInfraMonitoringFiltersHosts();
const [orderBy, setOrderBy] = useInfraMonitoringOrderByHosts();
const [showFilters, setShowFilters] = useState<boolean>(true);
const [selectedHostName, setSelectedHostName] = useQueryState(
'hostName',
parseAsString.withDefault(''),
);
const handleOrderByChange = (
orderByValue: {
columnName: string;
order: 'asc' | 'desc';
} | null,
): void => {
setOrderBy(orderByValue);
};
const handleHostClick = (hostName: string): void => {
setSelectedHostName(hostName);
};
const { pageSize, setPageSize } = usePageSize('hosts');
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
REACT_QUERY_KEY.GET_HOST_LIST,
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
),
[pageSize, currentPage, filters, orderBy, selectedTime],
);
const { data, isFetching, isLoading, isError } = useQuery<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
const payload: HostListPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: filters ?? defaultFilters,
orderBy,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
return getHostLists(payload, signal);
},
enabled: true,
keepPreviousData: true,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
data,
]);
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const handleFiltersChange = useCallback(
(value: IBuilderQuery['filters']): void => {
const isNewFilterAdded = value?.items?.length !== filters?.items?.length;
setFilters(value ?? null);
handleChangeQueryData('filters', value);
if (isNewFilterAdded) {
setCurrentPage(1);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,
});
}
}
},
[filters, setFilters, setCurrentPage, handleChangeQueryData],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
total: data?.payload?.data?.total,
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,
});
}, [data?.payload?.data?.total]);
const selectedHostData = useMemo(() => {
if (!selectedHostName?.trim()) {
return null;
}
return (
hostMetricsData.find((host) => host.hostName === selectedHostName) || null
);
}, [selectedHostName, hostMetricsData]);
const handleCloseHostDetail = (): void => {
setSelectedHostName(null);
};
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const handleQuickFiltersChange = (query: Query): void => {
handleChangeQueryData('filters', query.builder.queryData[0].filters);
handleFiltersChange(query.builder.queryData[0].filters);
};
return (
<div className="hosts-list">
<div className="hosts-list-content">
{showFilters && (
<div className="hosts-quick-filters-container">
<div className="hosts-quick-filters-container-header">
<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="hosts-list-table-container">
<div className="hosts-list-table-header">
{!showFilters && (
<div className="quick-filters-toggle-container">
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
</div>
)}
<HostsListControls
filters={filters}
handleFiltersChange={handleFiltersChange}
showAutoRefresh={!selectedHostData}
/>
</div>
<HostsListTable
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
tableData={data}
hostMetricsData={hostMetricsData}
filters={filters ?? defaultFilters}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
onHostClick={handleHostClick}
pageSize={pageSize}
setPageSize={setPageSize}
setOrderBy={handleOrderByChange}
orderBy={orderBy}
/>
</div>
</div>
<HostMetricDetail
host={selectedHostData}
isModalTimeSelection
onClose={handleCloseHostDetail}
/>
</div>
);
}
export default HostsList;

View File

@@ -0,0 +1,72 @@
import { useCallback, useMemo } from 'react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import './InfraMonitoring.styles.scss';
function HostsListControls({
handleFiltersChange,
filters,
showAutoRefresh,
}: {
handleFiltersChange: (value: IBuilderQuery['filters']) => void;
filters: IBuilderQuery['filters'] | null;
showAutoRefresh: boolean;
}): JSX.Element {
const currentQuery = initialQueriesMap[DataSource.METRICS];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters,
},
],
},
}),
[currentQuery, filters],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleFiltersChange(value);
},
[handleFiltersChange],
);
return (
<div className="hosts-list-controls">
<div className="hosts-list-controls-left">
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={handleChangeTagFilters}
isInfraMonitoring
disableNavigationShortcuts
entity={K8sCategory.HOSTS}
/>
</div>
<div className="time-selector">
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}
export default HostsListControls;

View File

@@ -0,0 +1,271 @@
import React, { useCallback, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import {
Skeleton,
Spin,
Table,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { InfraMonitoringEvents } from 'constants/events';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
import HostsEmptyOrIncorrectMetrics from './HostsEmptyOrIncorrectMetrics';
import {
EmptyOrLoadingViewProps,
formatDataForTable,
getHostsListColumns,
HostRowData,
HostsListTableProps,
} from './utils';
function EmptyOrLoadingView(
viewState: EmptyOrLoadingViewProps,
): React.ReactNode {
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
const { isError, data } = viewState;
if (isError || data?.error || (data?.statusCode || 0) >= 300) {
return (
<ErrorContent
error={{
code: data?.statusCode || 500,
message: data?.error || 'Something went wrong',
}}
/>
);
}
if (viewState.showHostsEmptyState) {
return (
<HostsEmptyOrIncorrectMetrics
noData={!viewState.sentAnyHostMetricsData}
incorrectData={viewState.isSendingIncorrectK8SAgentMetrics}
/>
);
}
if (viewState.showEndTimeBeforeRetentionMessage) {
return (
<div className="hosts-empty-state-container">
<div className="hosts-empty-state-container-content">
<img className="eyes-emoji" src={eyesEmojiUrl} alt="eyes emoji" />
<div className="no-hosts-message">
<Typography.Title level={5} className="no-hosts-message-title">
Queried time range is before earliest host metrics
</Typography.Title>
<Typography.Text className="no-hosts-message-text">
Your requested end time is earlier than the earliest detected time of
host metrics data, please adjust your end time.
</Typography.Text>
</div>
</div>
</div>
);
}
if (viewState.showNoRecordsInSelectedTimeRangeMessage) {
return (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src={emptyStateUrl}
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Title level={5} className="no-filtered-hosts-title">
No host metrics found
</Typography.Title>
<Typography.Text className="no-filtered-hosts-message">
No host metrics in the selected time range and filters. Please adjust your
time range or filters.
</Typography.Text>
</div>
</div>
);
}
return null;
}
export default function HostsListTable({
isLoading,
isFetching,
isError,
tableData: data,
hostMetricsData,
filters,
onHostClick,
currentPage,
setCurrentPage,
pageSize,
setOrderBy,
setPageSize,
orderBy,
}: HostsListTableProps): JSX.Element {
const [defaultOrderBy] = useState(orderBy);
const columns = useMemo(() => getHostsListColumns(defaultOrderBy), [
defaultOrderBy,
]);
const sentAnyHostMetricsData = useMemo(
() => data?.payload?.data?.sentAnyHostMetricsData || false,
[data],
);
const isSendingIncorrectK8SAgentMetrics = useMemo(
() => data?.payload?.data?.isSendingK8SAgentMetrics || false,
[data],
);
const endTimeBeforeRetention = useMemo(
() => data?.payload?.data?.endTimeBeforeRetention || false,
[data],
);
const formattedHostMetricsData = useMemo(
() => formatDataForTable(hostMetricsData),
[hostMetricsData],
);
const totalCount = data?.payload?.data?.total || 0;
const handleTableChange: TableProps<HostRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter: SorterResult<HostRowData> | SorterResult<HostRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: sorter.order === 'ascend' ? 'asc' : 'desc',
});
} else {
setOrderBy(null);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleRowClick = (
record: HostRowData,
event: React.MouseEvent,
): void => {
if (isModifierKeyPressed(event)) {
const params = new URLSearchParams(window.location.search);
params.set('hostName', record.hostName);
openInNewTab(`${window.location.pathname}?${params.toString()}`);
return;
}
onHostClick(record.hostName);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,
});
};
const showHostsEmptyState =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
!filters.items.length &&
!endTimeBeforeRetention;
const showEndTimeBeforeRetentionMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
endTimeBeforeRetention &&
!filters.items.length;
const showNoRecordsInSelectedTimeRangeMessage =
!isFetching &&
!isLoading &&
formattedHostMetricsData.length === 0 &&
!showEndTimeBeforeRetentionMessage &&
!showHostsEmptyState;
const showTableLoadingState =
(isLoading || isFetching) && formattedHostMetricsData.length === 0;
const emptyOrLoadingView = EmptyOrLoadingView({
isError,
data,
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,
showEndTimeBeforeRetentionMessage,
showNoRecordsInSelectedTimeRangeMessage,
showTableLoadingState,
});
if (emptyOrLoadingView) {
return <>{emptyOrLoadingView}</>;
}
return (
<Table
className="hosts-list-table"
dataSource={showTableLoadingState ? [] : formattedHostMetricsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: (page, pageSize): void => {
setCurrentPage(page);
setPageSize(pageSize);
},
}}
scroll={{ x: true }}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
tableLayout="fixed"
rowKey={(record): string =>
(record as HostRowData & { key: string }).key ?? record.hostName
}
onChange={handleTableChange}
onRow={(record: HostRowData): Record<string, unknown> => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: 'clickable-row',
})}
/>
);
}

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