Compare commits
3 Commits
chore/sent
...
tvats-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6d841e3b | ||
|
|
98cf5ed280 | ||
|
|
78b52e28e2 |
8
.github/CODEOWNERS
vendored
@@ -290,14 +290,6 @@ go.mod @therealpandey
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
## Saved Views
|
||||
/frontend/src/container/SavedViews/ @aks07
|
||||
/frontend/src/container/Home/SavedViews/ @aks07
|
||||
/frontend/src/hooks/saveViews/ @aks07
|
||||
|
||||
## Bottom Strip
|
||||
/frontend/src/container/BottomStrip/ @aks07
|
||||
|
||||
## Storybook
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
|
||||
@@ -122,7 +122,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return signoz.NewMeterReporterProviderFactories(), "noop"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
return querier.NewHandler(ps, q, a, querier.NewBudget(config.Querier))
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
|
||||
@@ -173,8 +173,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return factories, "http"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
queryBudget := querier.NewBudget(config.Querier)
|
||||
communityHandler := querier.NewHandler(ps, q, a, queryBudget)
|
||||
return eequerier.NewHandler(ps, q, communityHandler, queryBudget)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, dashboardModule dashboard.Module, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
defStore := pkgcloudintegration.NewServiceDefinitionStore()
|
||||
|
||||
@@ -178,6 +178,8 @@ querier:
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
# The maximum time range of a query range request. Set to 0 to disable.
|
||||
max_time_range: 17520h
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -2239,8 +2239,6 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
@@ -2250,79 +2248,13 @@ components:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionMethod:
|
||||
enum:
|
||||
- manual
|
||||
- agent
|
||||
type: string
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
- telemetryCollectionStrategy
|
||||
type: object
|
||||
CloudintegrationtypesGCPLogsCollectionStrategy:
|
||||
nullable: true
|
||||
properties:
|
||||
sinkFilter:
|
||||
type: string
|
||||
required:
|
||||
- sinkFilter
|
||||
type: object
|
||||
CloudintegrationtypesGCPMetricsCollectionStrategy:
|
||||
nullable: true
|
||||
properties:
|
||||
metricNames:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- metricNames
|
||||
type: object
|
||||
CloudintegrationtypesGCPPostableAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
@@ -2345,16 +2277,6 @@ components:
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPTelemetryCollectionStrategy:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
|
||||
required:
|
||||
- metrics
|
||||
- logs
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -2491,7 +2413,7 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -2726,6 +2648,7 @@ components:
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
|
||||
@@ -7,66 +7,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
var metricNames []string
|
||||
var sinkFilters []string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if svcDef.TelemetryCollectionStrategy.GCP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.GCP
|
||||
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
|
||||
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
|
||||
}
|
||||
|
||||
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
|
||||
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
|
||||
}
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
|
||||
}, nil
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
GCP: connectionArtifact,
|
||||
}, nil
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
@@ -76,7 +34,3 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -170,6 +171,18 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -182,36 +195,41 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
if account.RemovedAt != nil {
|
||||
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
|
||||
}
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
return nil, errors.Newf(
|
||||
errors.TypeAlreadyExists,
|
||||
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
|
||||
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
|
||||
)
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -480,45 +498,11 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
|
||||
}
|
||||
|
||||
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
if err == nil {
|
||||
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
|
||||
}
|
||||
// NOTE: not adding stats for services for now.
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
|
||||
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
response := cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
providerAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
)
|
||||
|
||||
if account.AgentReport == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if syncState == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
account.UpdateSyncState(syncState)
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
|
||||
@@ -21,13 +21,15 @@ type handler struct {
|
||||
set factory.ProviderSettings
|
||||
querier querier.Querier
|
||||
community querier.Handler
|
||||
budget querier.Budget
|
||||
}
|
||||
|
||||
func NewHandler(set factory.ProviderSettings, querier querier.Querier, community querier.Handler) querier.Handler {
|
||||
func NewHandler(set factory.ProviderSettings, querier querier.Querier, community querier.Handler, budget querier.Budget) querier.Handler {
|
||||
return &handler{
|
||||
set: set,
|
||||
querier: querier,
|
||||
community: community,
|
||||
budget: budget,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +60,11 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.budget.AdmitTimeRange(queryRangeRequest.Start, queryRangeRequest.End); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
|
||||
@@ -30,7 +30,6 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { hasNoAppFrames } from 'utils/sentry';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -367,11 +366,6 @@ function App(): JSX.Element {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dev serves source from /src/, so every frame would look foreign there.
|
||||
if (process.env.NODE_ENV === 'production' && hasNoAppFrames(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
|
||||
@@ -3792,12 +3792,7 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesGCPConnectionMethodDTO {
|
||||
manual = 'manual',
|
||||
agent = 'agent',
|
||||
}
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4044,10 +4039,7 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
@@ -4086,62 +4078,8 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
sinkFilter: string;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
metricNames: string[];
|
||||
} | null;
|
||||
|
||||
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
|
||||
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
|
||||
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
@@ -4296,7 +4234,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -4439,9 +4377,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
projectIds: string[];
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -2,7 +2,6 @@ export const GlobalShortcuts = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -24,6 +23,7 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -34,7 +34,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -45,6 +44,7 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -59,7 +59,6 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToLogs: 'Navigate to Logs Explorer',
|
||||
NavigateToDashboards: 'Navigate to Dashboards List',
|
||||
NavigateToAlerts: 'Navigate to Alerts List',
|
||||
NavigateToNotificationChannels: 'Navigate to Notification Channels',
|
||||
NavigateToExceptions: 'Navigate to Exceptions List',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
@@ -69,6 +68,8 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.waitingMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
import AgentSetupCommand from './AgentSetupCommand';
|
||||
|
||||
import styles from './AgentSetup.module.scss';
|
||||
|
||||
interface AgentSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onCommandGenerated: () => void;
|
||||
/** Asks to leave the setup; the parent may confirm before closing. */
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AgentSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onCommandGenerated,
|
||||
onCancel,
|
||||
onClose,
|
||||
}: AgentSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
generateCommand,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommand,
|
||||
commandProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
} = useAgentAccountSetup({ onClose, onCommandGenerated });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading || Boolean(connectionCommand)}
|
||||
/>
|
||||
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
|
||||
{connectionCommand ? (
|
||||
<>
|
||||
<AgentSetupCommand
|
||||
deploymentProjectId={commandProjectId}
|
||||
command={connectionCommand}
|
||||
/>
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon={false}
|
||||
title={
|
||||
<div className={styles.waitingMessage}>
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
Waiting for GCP account connection, retrying in 10 secs...
|
||||
</div>
|
||||
}
|
||||
testId="gcp-agent-waiting"
|
||||
/>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
title="We couldn't establish a connection to your GCP account. Please try again"
|
||||
testId="gcp-agent-connection-error"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
/>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to generate the GCP setup command"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{!connectionCommand && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(generateCommand)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-generate-command-btn"
|
||||
>
|
||||
Generate Command
|
||||
</Button>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={checkConnectionAgain}
|
||||
testId="gcp-check-connection-btn"
|
||||
>
|
||||
Check Again
|
||||
</Button>
|
||||
)}
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetup;
|
||||
@@ -1,31 +0,0 @@
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.commandsHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
|
||||
// the code theme sets the block's margin and padding inline, hence !important.
|
||||
.command {
|
||||
> div:first-child {
|
||||
top: var(--spacing-6);
|
||||
right: var(--spacing-6);
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
// The theme's top margin pushes the bordered block under the copy button.
|
||||
margin: 0 !important;
|
||||
// Keeps long lines from running underneath the copy button.
|
||||
padding-right: var(--spacing-24) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CodeBlock from 'components/CodeBlock/CodeBlock';
|
||||
|
||||
import styles from './AgentSetupCommand.module.scss';
|
||||
|
||||
interface AgentSetupCommandProps {
|
||||
deploymentProjectId: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function AgentSetupCommand({
|
||||
deploymentProjectId,
|
||||
command,
|
||||
}: AgentSetupCommandProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commands} data-testid="gcp-agent-command">
|
||||
<div className={styles.commandsHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Deploy the SigNoz agent
|
||||
</Typography.Text>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Run this command in Google Cloud Shell, or in a terminal where gcloud is
|
||||
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
|
||||
contains your SigNoz keys, so do not share it.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CodeBlock language="bash" code={command} className={styles.command} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetupCommand;
|
||||
@@ -1,183 +0,0 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
onCommandGenerated,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
AgentSetupApiMock,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectionCredentials,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpConnectionCommand,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup form', () => {
|
||||
let api: AgentSetupApiMock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api = mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('asks only for the deployment project, region and credentials', async () => {
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
|
||||
connectionCredentials.sigNozApiUrl,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-region-select'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
|
||||
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
|
||||
'SigNoz integration sets up everything needed',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks submission and surfaces validation errors when the form is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Please enter the deployment project ID'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Please select a region')).toBeInTheDocument();
|
||||
expect(api.createAccountPayload).toBeNull();
|
||||
});
|
||||
|
||||
it('creates an agent account and shows its setup command', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(api.createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
|
||||
projectIds: [DEPLOYMENT_PROJECT_ID],
|
||||
},
|
||||
},
|
||||
credentials: connectionCredentials,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
|
||||
gcpConnectionCommand.cliCommand,
|
||||
);
|
||||
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-deployment-project-id-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
expect(onCommandGenerated).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(api.checkInCalled).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the backend error inline and keeps the form when creation fails', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
message:
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks the connection method and keeps the submitted project while the command is generating', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'-edited',
|
||||
);
|
||||
|
||||
const commands = await screen.findByTestId('gcp-agent-command');
|
||||
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
|
||||
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup connection polling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('waits for the agent after showing the command', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-waiting');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-check-connection-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the agent checks in', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a timeout and lets the user check again', async () => {
|
||||
const realNow = Date.now.bind(Date);
|
||||
let clockOffset = 0;
|
||||
const nowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => realNow() + clockOffset);
|
||||
|
||||
let pollCount = 0;
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
// Polling started before this response, so jumping the clock
|
||||
// past the 10 minute window makes this first poll time out.
|
||||
clockOffset += 11 * 60 * 1000;
|
||||
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-check-connection-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a retryable error when the connection status cannot be fetched', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
|
||||
import AgentSetup from '../AgentSetup';
|
||||
import SetupDrawerLayout from '../../SetupDrawerLayout';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CHECK_IN_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
|
||||
|
||||
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
|
||||
|
||||
export const onClose = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onClose']
|
||||
>;
|
||||
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onConnectionMethodChange']
|
||||
>;
|
||||
export const onCommandGenerated = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCommandGenerated']
|
||||
>;
|
||||
export const onCancel = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCancel']
|
||||
>;
|
||||
|
||||
export const renderAgentSetup = (): void => {
|
||||
render(
|
||||
<SetupDrawerLayout onClose={onClose}>
|
||||
{(footerSlot): JSX.Element => (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={onConnectionMethodChange}
|
||||
onCommandGenerated={onCommandGenerated}
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</SetupDrawerLayout>,
|
||||
);
|
||||
};
|
||||
|
||||
export const fillAgentForm = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
};
|
||||
|
||||
export interface AgentSetupApiMock {
|
||||
readonly createAccountPayload: Record<string, unknown> | null;
|
||||
readonly checkInCalled: boolean;
|
||||
}
|
||||
|
||||
/** Registers the agent setup endpoints; the polled account stays pending. */
|
||||
export const mockAgentSetupApi = (): AgentSetupApiMock => {
|
||||
let createAccountPayload: Record<string, unknown> | null = null;
|
||||
let checkInCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
// check_in is registered first, it is a more specific path than
|
||||
// /accounts and msw matches handlers in registration order.
|
||||
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
|
||||
checkInCalled = true;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
|
||||
createAccountPayload = await req.json();
|
||||
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
|
||||
}),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
get createAccountPayload(): Record<string, unknown> | null {
|
||||
return createAccountPayload;
|
||||
},
|
||||
get checkInCalled(): boolean {
|
||||
return checkInCalled;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
.setupDrawer {
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
// Input and ComboboxSimple default to --border borders, inherited text and
|
||||
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
|
||||
// brighter values and duller placeholders. Backgrounds are left alone — both
|
||||
// components default to transparent and every field sits on an --l2-background
|
||||
// surface already. Focus borders stay at their per-component defaults.
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l2-border);
|
||||
--input-foreground: var(--l1-foreground);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
--combobox-trigger-border-color: var(--l2-border);
|
||||
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.projectIdsSelect {
|
||||
:global(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector),
|
||||
&:global(.ant-select-focused) :global(.ant-select-selector) {
|
||||
border-color: var(--l2-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
|
||||
:global(.ant-select-selection-placeholder),
|
||||
:global(.ant-select-selection-search-input),
|
||||
:global(.ant-select-selection-item) {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-search-input) {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item-remove) {
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
|
||||
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import AgentSetup from './AgentSetup/AgentSetup';
|
||||
import ManualSetup from './ManualSetup/ManualSetup';
|
||||
import SetupDrawerLayout from './SetupDrawerLayout';
|
||||
import ConnectionSecretsFields from './ConnectionSecretsFields';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FlowSelector from './FlowSelector';
|
||||
import SetupGuideCallout from './SetupGuideCallout';
|
||||
import { GcpSetupFormValues, SetupFlow } from './types';
|
||||
|
||||
import styles from './CloudAccountSetupDrawer.module.scss';
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
const DEFAULT_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
function CloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
const [connectionMethod, setConnectionMethod] =
|
||||
useState<CloudintegrationtypesGCPConnectionMethodDTO>(
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
);
|
||||
// Set once the agent command exists; closing after that loses the pending setup.
|
||||
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useCloudAccountSetupDrawer({ onClose });
|
||||
|
||||
const requestClose = (): void => {
|
||||
if (hasGeneratedCommand) {
|
||||
setIsCloseConfirmOpen(true);
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
});
|
||||
|
||||
const [flow, setFlow] = useState<SetupFlow>('manual');
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footerContainer}>
|
||||
{submitError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={clearSubmitError}
|
||||
title="Failed to connect GCP account"
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{submitError}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetupDrawerLayout onClose={requestClose}>
|
||||
{(footerSlot): JSX.Element =>
|
||||
connectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent ? (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onCommandGenerated={(): void => setHasGeneratedCommand(true)}
|
||||
onCancel={requestClose}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<ManualSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
</SetupDrawerLayout>
|
||||
<DialogWrapper
|
||||
open={isCloseConfirmOpen}
|
||||
onOpenChange={setIsCloseConfirmOpen}
|
||||
title="Close GCP agent setup?"
|
||||
testId="gcp-close-setup-confirm"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="highlight-danger"
|
||||
onClick={onClose}
|
||||
testId="gcp-close-setup-btn"
|
||||
>
|
||||
Close Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsCloseConfirmOpen(false)}
|
||||
testId="gcp-continue-setup-btn"
|
||||
>
|
||||
Continue Setup
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography>
|
||||
The agent has not connected yet. If you close now, you will have to start
|
||||
the installation process again with a new setup command.
|
||||
</Typography>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="base"
|
||||
footer={footer}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<FlowSelector value={flow} onChange={setFlow} />
|
||||
<SetupGuideCallout />
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip="The GCP region where your OTel Collector will be deployed"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Select
|
||||
id="gcp-project-ids-select"
|
||||
className={cx(styles.fullWidth, styles.projectIdsSelect)}
|
||||
mode="tags"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value)}
|
||||
placeholder="Add project IDs…"
|
||||
tokenSeparators={[',', ' ']}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="gcp-project-ids-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ConnectionMethodSelector.module.scss';
|
||||
|
||||
interface ConnectionMethodSelectorProps {
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
onChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ConnectionMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ConnectionMethodSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<Typography.Text
|
||||
weight="bold"
|
||||
size="base"
|
||||
className={styles.drawerSurfaceHead}
|
||||
>
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
textOverflow="wrap"
|
||||
value={value}
|
||||
onChange={(next): void =>
|
||||
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
|
||||
}
|
||||
disabled={disabled}
|
||||
disabledTooltip="The connection method can't be changed once setup has started"
|
||||
items={[
|
||||
{
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
testId: 'gcp-connection-method-manual',
|
||||
label: (
|
||||
<>
|
||||
<Typography.Text weight="semibold" size="base" display="block">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
testId: 'gcp-connection-method-agent',
|
||||
label: (
|
||||
<>
|
||||
<Typography.Text weight="semibold" size="base" display="block">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionMethodSelector.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ConnectionMethodSelector;
|
||||
@@ -21,6 +21,10 @@
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FormField from './FormField';
|
||||
import { GcpSetupFormValues } from './types';
|
||||
import { SecretFieldType, validateSecretValue } from './validators';
|
||||
import styles from './ConnectionSecretsFields.module.scss';
|
||||
@@ -142,32 +141,43 @@ function ConnectionSecretsFields({
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
validateSecretValue(field.label, field.type, value),
|
||||
}}
|
||||
render={({ field: rhfField, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id={field.testId}
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder={field.placeholder}
|
||||
value={rhfField.value}
|
||||
onChange={(e): void => rhfField.onChange(e.target.value)}
|
||||
testId={field.testId}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<div key={field.name} className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
/>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
validateSecretValue(field.label, field.type, value),
|
||||
}}
|
||||
render={({ field: rhfField, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id={field.testId}
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder={field.placeholder}
|
||||
value={rhfField.value}
|
||||
onChange={(e): void => rhfField.onChange(e.target.value)}
|
||||
testId={field.testId}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
composes: drawerSurfaceHead from './shared.module.scss';
|
||||
}
|
||||
|
||||
.methodRadioDesc {
|
||||
.flowRadioTitle {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flowRadioDesc {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { SetupFlow } from './types';
|
||||
import styles from './FlowSelector.module.scss';
|
||||
|
||||
interface FlowSelectorProps {
|
||||
value: SetupFlow;
|
||||
onChange: (flow: SetupFlow) => void;
|
||||
}
|
||||
|
||||
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
textOverflow="wrap"
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as SetupFlow)}
|
||||
items={[
|
||||
{
|
||||
value: 'manual',
|
||||
testId: 'gcp-flow-manual',
|
||||
label: (
|
||||
<>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'agent',
|
||||
testId: 'gcp-flow-agent',
|
||||
disabled: true,
|
||||
disabledTooltip: 'Soon',
|
||||
label: (
|
||||
<>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Badge color="primary" variant="solid">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowSelector;
|
||||
@@ -1,7 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from './FieldLabel';
|
||||
|
||||
import styles from './FormField.module.scss';
|
||||
|
||||
interface FormFieldProps {
|
||||
htmlFor: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
required?: boolean;
|
||||
error?: FieldError;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** A labelled setup drawer field with its validation message below the input. */
|
||||
function FormField({
|
||||
htmlFor,
|
||||
label,
|
||||
tooltip,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
}: FormFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={htmlFor}
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
required={required}
|
||||
/>
|
||||
{children}
|
||||
{error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormField.defaultProps = {
|
||||
required: false,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
@@ -1,3 +0,0 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ProjectIdsInput from '../../ProjectIdsInput';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import FormField from '../FormField';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
|
||||
import styles from './ManualSetup.module.scss';
|
||||
|
||||
interface ManualSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ManualSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onClose,
|
||||
}: ManualSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useManualAccountSetup({ onClose });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<ProjectIdsInput
|
||||
id="gcp-project-ids-select"
|
||||
testId="gcp-project-ids-select"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to connect GCP account"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManualSetup;
|
||||
@@ -1,14 +0,0 @@
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
import styles from './SetupDrawerFooter.module.scss';
|
||||
|
||||
interface SetupDrawerFooterProps {
|
||||
slot: HTMLDivElement | null;
|
||||
errorTitle: string;
|
||||
error: string | null;
|
||||
onDismissError: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function SetupDrawerFooter({
|
||||
slot,
|
||||
errorTitle,
|
||||
error,
|
||||
onDismissError,
|
||||
onCancel,
|
||||
children,
|
||||
}: SetupDrawerFooterProps): JSX.Element | null {
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.footerContainer}>
|
||||
{error && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={onDismissError}
|
||||
title={errorTitle}
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
slot,
|
||||
);
|
||||
}
|
||||
|
||||
SetupDrawerFooter.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default SetupDrawerFooter;
|
||||
@@ -1,36 +0,0 @@
|
||||
.setupDrawer {
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// The region menu renders in place (no portal), so it needs its own surface
|
||||
// to sit above the fields below it.
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
|
||||
.footerSlot {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import styles from './SetupDrawerLayout.module.scss';
|
||||
|
||||
interface SetupDrawerLayoutProps {
|
||||
onClose: () => void;
|
||||
/** Receives the footer slot element that SetupDrawerFooter portals into. */
|
||||
children: (footerSlot: HTMLDivElement | null) => ReactNode;
|
||||
}
|
||||
|
||||
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
|
||||
function SetupDrawerLayout({
|
||||
onClose,
|
||||
children,
|
||||
}: SetupDrawerLayoutProps): JSX.Element {
|
||||
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="extra-wide"
|
||||
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
|
||||
testId="gcp-setup-drawer"
|
||||
>
|
||||
{children(footerSlot)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupDrawerLayout;
|
||||
@@ -4,30 +4,24 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './SetupGuideCallout.module.scss';
|
||||
|
||||
interface SetupGuideCalloutProps {
|
||||
description: string;
|
||||
docsUrl: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
const GCP_INTEGRATION_DOCS_URL =
|
||||
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
|
||||
|
||||
function SetupGuideCallout({
|
||||
description,
|
||||
docsUrl,
|
||||
linkLabel,
|
||||
}: SetupGuideCalloutProps): JSX.Element {
|
||||
function SetupGuideCallout(): JSX.Element {
|
||||
return (
|
||||
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
|
||||
<Typography.Text as="span" size="base">
|
||||
{description}
|
||||
Please go through our GCP integration guide, which covers all prerequisites
|
||||
— service account, IAM roles, and resource setup.
|
||||
</Typography.Text>
|
||||
<a
|
||||
className={styles.guideLink}
|
||||
href={docsUrl}
|
||||
href={GCP_INTEGRATION_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="gcp-setup-guide-link"
|
||||
>
|
||||
{linkLabel}
|
||||
GCP integration guide
|
||||
<ArrowUpRight size={12} />
|
||||
</a>
|
||||
</Callout>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
export const MANUAL_SETUP_GUIDE = {
|
||||
description:
|
||||
'Please follow our setup guide to create the resources needed to complete the integration.',
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'View setup guide',
|
||||
};
|
||||
|
||||
export const AGENT_SETUP_GUIDE = {
|
||||
description:
|
||||
'SigNoz integration sets up everything needed to collect telemetry from your project.',
|
||||
// TODO: point to the agent setup docs once they are published.
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'Learn more',
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FormField from '../FormField';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
|
||||
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
|
||||
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
|
||||
};
|
||||
|
||||
interface DeploymentProjectIdFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}
|
||||
|
||||
function DeploymentProjectIdField({
|
||||
control,
|
||||
connectionMethod,
|
||||
}: DeploymentProjectIdFieldProps): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip={TOOLTIPS[connectionMethod]}
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentProjectIdField;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FormField from '../FormField';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
|
||||
'The GCP region where your OTel Collector will be deployed',
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
|
||||
'The GCP region where the agent resources are deployed',
|
||||
};
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
interface DeploymentRegionFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}
|
||||
|
||||
function DeploymentRegionField({
|
||||
control,
|
||||
connectionMethod,
|
||||
}: DeploymentRegionFieldProps): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip={TOOLTIPS[connectionMethod]}
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, styles.regionSelect, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentRegionField;
|
||||
@@ -1,19 +0,0 @@
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.regionSelect {
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
&.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
|
||||
export type SetupFlow = 'manual' | 'agent';
|
||||
|
||||
export interface GcpSetupFormValues {
|
||||
accountName: string;
|
||||
deploymentProjectId: string;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UseFormSetValue } from 'react-hook-form';
|
||||
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
export function usePrefillCredentials(
|
||||
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
|
||||
setValue: UseFormSetValue<GcpSetupFormValues>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
}
|
||||
@@ -3,15 +3,13 @@ import { useQueryClient } from 'react-query';
|
||||
import { Save } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Form } from 'antd';
|
||||
import { Form, Select } from 'antd';
|
||||
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
|
||||
|
||||
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
|
||||
import ProjectIdsInput from '../ProjectIdsInput';
|
||||
|
||||
import styles from './AccountSettingsDrawer.module.scss';
|
||||
|
||||
@@ -42,10 +40,6 @@ function AccountSettingsDrawer({
|
||||
() => ('project_ids' in account.config ? account.config : null),
|
||||
[account.config],
|
||||
);
|
||||
// The backend rejects updates to agent accounts, so their settings are read-only.
|
||||
const isAgentAccount =
|
||||
gcpConfig?.connection_method ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent;
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
@@ -71,23 +65,20 @@ function AccountSettingsDrawer({
|
||||
handleClose();
|
||||
}}
|
||||
cloudProvider={INTEGRATION_TYPES.GCP}
|
||||
gcpConnectionMethod={gcpConfig?.connection_method}
|
||||
/>
|
||||
{!isAgentAccount && (
|
||||
<Button
|
||||
disabledTooltip="Change the project IDs first"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
testId="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabledTooltip="Change the project IDs first"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
testId="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -131,42 +122,32 @@ function AccountSettingsDrawer({
|
||||
|
||||
<div className={styles.regionSelector}>
|
||||
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
|
||||
{isAgentAccount ? (
|
||||
<div
|
||||
className={styles.regionSelectorDescription}
|
||||
data-testid="gcp-agent-monitored-projects"
|
||||
>
|
||||
{gcpConfig?.project_ids.join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ProjectIdsInput
|
||||
id="gcp-edit-project-ids-select"
|
||||
testId="gcp-edit-project-ids-select"
|
||||
value={projectIds}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={projectIds}
|
||||
tokenSeparators={[',']}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
data-testid="gcp-edit-project-ids-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.projectIds {
|
||||
// The multi-value trigger is content-box, so full width needs its padding counted in.
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ProjectIdsInput.module.scss';
|
||||
|
||||
interface ProjectIdsInputProps {
|
||||
id: string;
|
||||
testId: string;
|
||||
value: string[];
|
||||
onChange: (projectIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
|
||||
function ProjectIdsInput({
|
||||
id,
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ProjectIdsInputProps): JSX.Element {
|
||||
return (
|
||||
<ComboboxSimple
|
||||
id={id}
|
||||
testId={testId}
|
||||
className={cx(styles.projectIds, className)}
|
||||
multiple
|
||||
allowCreate
|
||||
value={value}
|
||||
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
|
||||
placeholder="Add project IDs…"
|
||||
inputPlaceholder="Type a project ID and press Enter"
|
||||
emptyPlaceholder="Type a project ID to add it"
|
||||
withPortal={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectIdsInput.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default ProjectIdsInput;
|
||||
@@ -1,18 +1,19 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNT_URL,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
gcpAccountConfig,
|
||||
listAccountsResponse,
|
||||
} from './mockData';
|
||||
import { addProjectIds } from './projectIdsTestUtils';
|
||||
|
||||
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
@@ -30,29 +31,31 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
const onClose = jest.fn();
|
||||
const setActiveAccount = jest.fn();
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
<MockQueryClientProvider>
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
/** Each selected project ID renders as a pill with its own remove button. */
|
||||
const getProjectIdRemoveButtons = (): HTMLElement[] =>
|
||||
/** The antd tags Select renders its text input inside the testId wrapper. */
|
||||
const getProjectIdsInput = (): HTMLElement =>
|
||||
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
|
||||
'combobox',
|
||||
);
|
||||
|
||||
/** Each selected tag carries an antd "close" icon that removes it. */
|
||||
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
|
||||
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
|
||||
/^Remove /,
|
||||
'close',
|
||||
);
|
||||
|
||||
describe('GCP AccountSettingsDrawer', () => {
|
||||
@@ -83,9 +86,8 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
screen.getByText(gcpAccountConfig.deployment_region),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
|
||||
gcpAccountConfig.project_ids.forEach((projectId) => {
|
||||
expect(projectIdsInput).toHaveTextContent(projectId);
|
||||
expect(screen.getByTitle(projectId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +100,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
'true',
|
||||
);
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
@@ -112,7 +114,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
@@ -139,7 +141,8 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).toHaveBeenCalledWith({
|
||||
...gcpAccount,
|
||||
config: {
|
||||
...gcpAccountConfig,
|
||||
deployment_region: gcpAccountConfig.deployment_region,
|
||||
deployment_project_id: gcpAccountConfig.deployment_project_id,
|
||||
project_ids: ['project-a', 'project-b', 'project-c'],
|
||||
},
|
||||
});
|
||||
@@ -156,9 +159,9 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
renderDrawer();
|
||||
|
||||
// Strip every tag via its remove icon; the list shrinks as we go.
|
||||
while (getProjectIdRemoveButtons().length > 0) {
|
||||
while (getProjectIdTagRemoveButtons().length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await user.click(getProjectIdRemoveButtons()[0]);
|
||||
await user.click(getProjectIdTagRemoveButtons()[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -191,7 +194,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
@@ -209,4 +212,31 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
gcpAgentAccount,
|
||||
listAccountsResponse,
|
||||
} from './mockData';
|
||||
|
||||
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
|
||||
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
|
||||
render(
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={account}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('GCP AccountSettingsDrawer by connection method', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listAccountsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
|
||||
'my-agent-project',
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('gcp-edit-project-ids-select'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-update-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
const generateAgentCommand = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
await screen.findByTestId('gcp-agent-command');
|
||||
};
|
||||
|
||||
describe('GCP setup drawer close confirmation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes right away when no setup command has been generated', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks before cancelling a pending agent setup and can continue it', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
await screen.findByText(/start the installation process again/i);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the user confirms', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes without asking once the agent connects', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
@@ -14,9 +15,8 @@ import {
|
||||
GCP_CHECK_IN_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
} from './mockData';
|
||||
import { addProjectIds } from './projectIdsTestUtils';
|
||||
|
||||
// `useManualAccountSetup` imports logEvent by relative path, which the
|
||||
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept — so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
@@ -24,12 +24,14 @@ jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
const onClose = jest.fn();
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<CloudAccountSetupDrawer onClose={onClose} />
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('GCP CloudAccountSetupDrawer', () => {
|
||||
@@ -107,13 +109,6 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
});
|
||||
|
||||
it('creates the account, checks the agent in, and closes the drawer', async () => {
|
||||
// A slow check-in keeps the connection in flight long enough to observe it.
|
||||
server.use(
|
||||
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
|
||||
checkInPayload = await req.json();
|
||||
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
@@ -132,21 +127,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
|
||||
await addProjectIds(user, 'gcp-project-ids-select', [
|
||||
'project-a',
|
||||
'project-b',
|
||||
]);
|
||||
const projectIdsInput = document.querySelector(
|
||||
'#gcp-project-ids-select',
|
||||
) as HTMLInputElement;
|
||||
await user.type(projectIdsInput, 'project-a,project-b,');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
|
||||
// Switching away mid-request would let its success close the other form.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createAccountPayload).not.toBeNull();
|
||||
});
|
||||
@@ -154,7 +141,6 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
expect(createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'manual',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
projectIds: ['project-a', 'project-b'],
|
||||
@@ -207,7 +193,10 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
|
||||
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
|
||||
const projectIdsInput = document.querySelector(
|
||||
'#gcp-project-ids-select',
|
||||
) as HTMLInputElement;
|
||||
await user.type(projectIdsInput, 'project-a,');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
describe('GCP setup drawer connection method switch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('switches between the manual and agent connection methods inside the same drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
|
||||
await screen.findByTestId('gcp-generate-command-btn');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-connect-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-manual'));
|
||||
|
||||
await screen.findByTestId('gcp-account-name-input');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The drawer itself stays mounted, so it does not replay its open animation.
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
});
|
||||
|
||||
it('keeps the drawer width the same across connection methods and setup steps', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-command');
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CloudAccount,
|
||||
GCPCloudAccountConfig,
|
||||
@@ -20,7 +17,6 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
|
||||
|
||||
export const gcpAccountConfig: GCPCloudAccountConfig = {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deployment_region: 'asia-south1',
|
||||
deployment_project_id: 'my-deployment-project-123',
|
||||
project_ids: ['project-a', 'project-b'],
|
||||
@@ -34,19 +30,6 @@ export const gcpAccount: CloudAccount = {
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const gcpAgentAccount: CloudAccount = {
|
||||
id: 'acc-gcp-agent-1',
|
||||
cloud_account_id: 'acc-gcp-agent-1',
|
||||
providerAccountId: 'my-agent-project',
|
||||
config: {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-agent-project',
|
||||
project_ids: ['my-agent-project'],
|
||||
},
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const listAccountsResponse = {
|
||||
status: 'success',
|
||||
data: { accounts: [] },
|
||||
@@ -80,53 +63,3 @@ export const checkInResponse = {
|
||||
status: 'success',
|
||||
data: {},
|
||||
};
|
||||
|
||||
export const gcpConnectionCommand = {
|
||||
cliCommand:
|
||||
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
|
||||
};
|
||||
|
||||
export const createAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
connectionArtifact: { gcp: gcpConnectionCommand },
|
||||
},
|
||||
};
|
||||
|
||||
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
|
||||
|
||||
const pendingAgentAccount = {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'asia-south1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const pendingAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: pendingAgentAccount,
|
||||
};
|
||||
|
||||
export const connectedAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
...pendingAgentAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport: {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
|
||||
export const addProjectIds = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
testId: string,
|
||||
projectIds: string[],
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId(testId));
|
||||
const input = await screen.findByPlaceholderText(
|
||||
'Type a project ID and press Enter',
|
||||
);
|
||||
for (const projectId of projectIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await user.type(input, `${projectId}{Enter}`);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Modal } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import {
|
||||
INTEGRATION_TELEMETRY_EVENTS,
|
||||
@@ -18,12 +17,10 @@ function RemoveIntegrationAccount({
|
||||
cloudProvider,
|
||||
accountId,
|
||||
onRemoveIntegrationAccountSuccess,
|
||||
gcpConnectionMethod,
|
||||
}: {
|
||||
cloudProvider: string;
|
||||
accountId: string;
|
||||
onRemoveIntegrationAccountSuccess: () => void;
|
||||
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -47,7 +44,7 @@ function RemoveIntegrationAccount({
|
||||
},
|
||||
});
|
||||
const handleOk = (): void => {
|
||||
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
accountId,
|
||||
integration: cloudProvider,
|
||||
});
|
||||
@@ -76,20 +73,6 @@ function RemoveIntegrationAccount({
|
||||
manually when connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (
|
||||
cloudProvider === INTEGRATION_TYPES.GCP &&
|
||||
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
|
||||
) {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will stop SigNoz from monitoring it, and the agent
|
||||
will disable the telemetry resources it created in your GCP project. <br />
|
||||
<br />
|
||||
After that, you can delete the signoz-integration-bootstrap Infrastructure
|
||||
Manager deployment, the signoz-integration-bootstrap service account and the
|
||||
signoz-integration secrets that were created while connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
|
||||
modalDescription = (
|
||||
<>
|
||||
@@ -146,8 +129,4 @@ function RemoveIntegrationAccount({
|
||||
);
|
||||
}
|
||||
|
||||
RemoveIntegrationAccount.defaultProps = {
|
||||
gcpConnectionMethod: undefined,
|
||||
};
|
||||
|
||||
export default RemoveIntegrationAccount;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
|
||||
|
||||
const gcpAccountDto: CloudintegrationtypesAccountDTO = {
|
||||
id: 'acc-gcp-1',
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: 'my-project',
|
||||
removedAt: null,
|
||||
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentProjectId: 'my-project',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-project'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('mapAccountDtoToGcpCloudAccount', () => {
|
||||
it('maps the GCP config, including its connection method', () => {
|
||||
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
|
||||
|
||||
expect(account?.config).toStrictEqual({
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-project',
|
||||
project_ids: ['my-project'],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an account that has no GCP config', () => {
|
||||
expect(
|
||||
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('skips an account the agent has not checked in for yet', () => {
|
||||
expect(
|
||||
mapAccountDtoToGcpCloudAccount({
|
||||
...gcpAccountDto,
|
||||
providerAccountId: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,7 @@ export function mapAccountDtoToAzureCloudAccount(
|
||||
export function mapAccountDtoToGcpCloudAccount(
|
||||
account: CloudintegrationtypesAccountDTO,
|
||||
): IntegrationCloudAccount | null {
|
||||
const gcpConfig = account.config.gcp;
|
||||
if (!account.providerAccountId || !gcpConfig) {
|
||||
if (!account.providerAccountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -60,10 +59,9 @@ export function mapAccountDtoToGcpCloudAccount(
|
||||
id: account.id,
|
||||
cloud_account_id: account.id,
|
||||
config: {
|
||||
connection_method: gcpConfig.connectionMethod,
|
||||
deployment_region: gcpConfig.deploymentRegion,
|
||||
deployment_project_id: gcpConfig.deploymentProjectId,
|
||||
project_ids: gcpConfig.projectIds,
|
||||
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
|
||||
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
|
||||
project_ids: account.config?.gcp?.projectIds ?? [],
|
||||
},
|
||||
status: {
|
||||
integration: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AWSCloudAccountConfig,
|
||||
AWSServiceConfig,
|
||||
@@ -103,7 +102,6 @@ export interface AzureCloudAccountConfig {
|
||||
}
|
||||
|
||||
export interface GCPCloudAccountConfig {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
deployment_region: string;
|
||||
deployment_project_id: string;
|
||||
project_ids: string[];
|
||||
|
||||
@@ -65,7 +65,8 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { checkVersionState, isModifierKeyPressed } from 'utils/app';
|
||||
import { checkVersionState } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -713,7 +714,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToNotificationChannels, () =>
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
|
||||
@@ -743,7 +744,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
|
||||
|
||||
@@ -343,6 +343,14 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: true,
|
||||
itemKey: 'account',
|
||||
},
|
||||
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
|
||||
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
|
||||
|
||||
export const onConnected = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onConnected']
|
||||
>;
|
||||
export const onTimeout = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onTimeout']
|
||||
>;
|
||||
export const onError = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onError']
|
||||
>;
|
||||
|
||||
export const callbacks = { onConnected, onTimeout, onError };
|
||||
|
||||
export const ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
|
||||
|
||||
const agentReport = {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
};
|
||||
|
||||
export const pendingAccount: CloudintegrationtypesAccountDTO = {
|
||||
id: ACCOUNT_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const connectedAccount: CloudintegrationtypesAccountDTO = {
|
||||
...pendingAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport,
|
||||
};
|
||||
|
||||
export const mockAccountResponses = (
|
||||
...accounts: CloudintegrationtypesAccountDTO[]
|
||||
): { requestCount: () => number } => {
|
||||
let count = 0;
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
|
||||
const account = accounts[Math.min(count, accounts.length - 1)];
|
||||
count += 1;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
|
||||
}),
|
||||
);
|
||||
return { requestCount: (): number => count };
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
useAgentConnectionPolling,
|
||||
} from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
ACCOUNT_URL,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onError,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
const renderPolling = (): void => {
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAgentConnectionPolling outcomes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps polling until the agent checks in, then reports the connection', async () => {
|
||||
const { requestCount } = mockAccountResponses(
|
||||
pendingAccount,
|
||||
pendingAccount,
|
||||
connectedAccount,
|
||||
);
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS);
|
||||
});
|
||||
await waitFor(() => expect(requestCount()).toBe(2));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
|
||||
);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat a removed account as connected', async () => {
|
||||
const { requestCount } = mockAccountResponses({
|
||||
...connectedAccount,
|
||||
removedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error when the account request fails', async () => {
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
useAgentConnectionPolling,
|
||||
} from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
const renderPolling = (
|
||||
initialEnabled: boolean,
|
||||
): ((enabled: boolean) => void) => {
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
|
||||
);
|
||||
return (enabled: boolean): void => rerender({ enabled });
|
||||
};
|
||||
|
||||
describe('useAgentConnectionPolling polling window', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not poll while disabled', async () => {
|
||||
const { requestCount } = mockAccountResponses(connectedAccount);
|
||||
renderPolling(false);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
|
||||
expect(requestCount()).toBe(0);
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a timeout once the deadline passes without a check-in', async () => {
|
||||
const { requestCount } = mockAccountResponses(pendingAccount);
|
||||
renderPolling(true);
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the timeout clock when polling is enabled, not on mount', async () => {
|
||||
const { requestCount } = mockAccountResponses(pendingAccount);
|
||||
const setEnabled = renderPolling(false);
|
||||
|
||||
// Longer than the whole window passes before polling is turned on.
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
|
||||
});
|
||||
setEnabled(true);
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,8 @@ export function useAccountSettingsDrawer({
|
||||
{
|
||||
onSuccess: () => {
|
||||
const nextConfig = {
|
||||
...accountConfig,
|
||||
deployment_region: accountConfig.deployment_region,
|
||||
deployment_project_id: accountConfig.deployment_project_id,
|
||||
project_ids: values.projectIds || [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
|
||||
import { useGcpAccountSetup } from './useGcpAccountSetup';
|
||||
|
||||
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
|
||||
|
||||
interface UseAgentAccountSetupProps {
|
||||
onClose: () => void;
|
||||
onCommandGenerated: () => void;
|
||||
}
|
||||
|
||||
interface UseAgentAccountSetup {
|
||||
isLoading: boolean;
|
||||
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
submitError: string | null;
|
||||
clearSubmitError: () => void;
|
||||
connectionCommand: string | null;
|
||||
/** The project the generated command targets, as submitted. */
|
||||
commandProjectId: string;
|
||||
modalState: ModalStateEnum;
|
||||
checkConnectionAgain: () => void;
|
||||
}
|
||||
|
||||
export function useAgentAccountSetup({
|
||||
onClose,
|
||||
onCommandGenerated,
|
||||
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
|
||||
const {
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
setSubmitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
} = useGcpAccountSetup({ onClose });
|
||||
|
||||
const [connectionCommand, setConnectionCommand] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [commandProjectId, setCommandProjectId] = useState('');
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [modalState, setModalState] = useState<ModalStateEnum>(
|
||||
ModalStateEnum.FORM,
|
||||
);
|
||||
|
||||
const generateCommand = useCallback(
|
||||
(values: GcpSetupFormValues): Promise<void> =>
|
||||
runSubmit(async () => {
|
||||
const account = await createGcpAccount(
|
||||
{
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
// The agent monitors only the project it is deployed in.
|
||||
projectIds: [values.deploymentProjectId],
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
const command = account.connectionArtifact.gcp?.cliCommand;
|
||||
if (!command) {
|
||||
setSubmitError(GENERATE_COMMAND_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountId(account.id);
|
||||
setCommandProjectId(values.deploymentProjectId);
|
||||
setConnectionCommand(command);
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
onCommandGenerated();
|
||||
|
||||
void logEvent('GCP Integration: Account connection command generated', {
|
||||
id: account.id,
|
||||
});
|
||||
}, GENERATE_COMMAND_ERROR),
|
||||
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
|
||||
);
|
||||
|
||||
const handleAgentConnected = useCallback(
|
||||
(account: CloudintegrationtypesAccountDTO): void => {
|
||||
handleConnected({
|
||||
cloudIntegrationId: account.id,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
},
|
||||
[handleConnected],
|
||||
);
|
||||
|
||||
const handleTimeout = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('GCP Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
const handlePollingError = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
}, []);
|
||||
|
||||
const checkConnectionAgain = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
accountId: accountId ?? '',
|
||||
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
|
||||
onConnected: handleAgentConnected,
|
||||
onTimeout: handleTimeout,
|
||||
onError: handlePollingError,
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
generateCommand,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommand,
|
||||
commandProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useGetAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
|
||||
export const POLL_INTERVAL_MS = 10 * 1000;
|
||||
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
interface UseAgentConnectionPollingProps {
|
||||
accountId: string;
|
||||
/** Polls exactly while true; the caller decides when there is an account to poll. */
|
||||
enabled: boolean;
|
||||
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
|
||||
onTimeout: () => void;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a GCP agent account until the agent checks in. The timeout is measured
|
||||
* from when polling gets enabled, so re-enabling it starts a fresh window.
|
||||
*/
|
||||
export function useAgentConnectionPolling({
|
||||
accountId,
|
||||
enabled,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
onError,
|
||||
}: UseAgentConnectionPollingProps): void {
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
startTimeRef.current = enabled ? Date.now() : null;
|
||||
}, [enabled]);
|
||||
|
||||
useGetAccount(
|
||||
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
select: (response): CloudintegrationtypesAccountDTO => response.data,
|
||||
onSuccess: (account) => {
|
||||
const isConnected =
|
||||
Boolean(account.providerAccountId) && account.removedAt === null;
|
||||
|
||||
if (isConnected) {
|
||||
onConnected(account);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = startTimeRef.current ?? Date.now();
|
||||
if (Date.now() - startTime >= CONNECTION_TIMEOUT_MS) {
|
||||
onTimeout();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onError();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,16 @@ import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
CreateAccountMutationResult,
|
||||
GetConnectionCredentialsQueryResult,
|
||||
invalidateListAccounts,
|
||||
useAgentCheckIn,
|
||||
useCreateAccount,
|
||||
useGetConnectionCredentials,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
@@ -21,36 +22,23 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
|
||||
interface UseGcpAccountSetupProps {
|
||||
interface UseCloudAccountSetupDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseGcpAccountSetup {
|
||||
interface UseCloudAccountSetupDrawer {
|
||||
isLoading: boolean;
|
||||
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
isLoading: boolean;
|
||||
submitError: string | null;
|
||||
setSubmitError: (message: string | null) => void;
|
||||
clearSubmitError: () => void;
|
||||
createGcpAccount: (
|
||||
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
values: GcpSetupFormValues,
|
||||
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
|
||||
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
|
||||
runSubmit: (
|
||||
work: () => Promise<void>,
|
||||
fallbackMessage: string,
|
||||
) => Promise<void>;
|
||||
handleConnected: (payload: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
|
||||
export function useGcpAccountSetup({
|
||||
export function useCloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
|
||||
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
@@ -60,6 +48,7 @@ export function useGcpAccountSetup({
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createAccount } = useCreateAccount();
|
||||
const { mutateAsync: checkIn } = useAgentCheckIn();
|
||||
const handleError = useAxiosError();
|
||||
|
||||
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
|
||||
@@ -74,15 +63,44 @@ export function useGcpAccountSetup({
|
||||
},
|
||||
);
|
||||
|
||||
const createGcpAccount = useCallback(
|
||||
async (
|
||||
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
values: GcpSetupFormValues,
|
||||
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
|
||||
const response = await createAccount({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
config: { gcp: gcpConfig },
|
||||
const handleClose = useCallback((): void => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleConnectionSuccess = useCallback(
|
||||
(payload: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string;
|
||||
}): void => {
|
||||
void logEvent('GCP Integration: Account connected', {
|
||||
cloudIntegrationId: payload.cloudIntegrationId,
|
||||
providerAccountId: payload.providerAccountId,
|
||||
});
|
||||
toast.success('GCP account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, queryClient],
|
||||
);
|
||||
|
||||
const connectAccount = useCallback(
|
||||
async (values: GcpSetupFormValues): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const payload: CloudintegrationtypesPostableAccountDTO = {
|
||||
config: {
|
||||
gcp: {
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
projectIds: values.projectIds || [],
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
// Cloud users can't edit these — the backend-provided credentials are
|
||||
// authoritative. Enterprise users have no backend defaults and enter
|
||||
@@ -92,69 +110,56 @@ export function useGcpAccountSetup({
|
||||
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
|
||||
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[connectionParams, createAccount],
|
||||
);
|
||||
// Step 1: create the integration account.
|
||||
const createResponse: CreateAccountMutationResult = await createAccount({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
const runSubmit = useCallback(
|
||||
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSubmitError(null);
|
||||
await work();
|
||||
const cloudIntegrationId = createResponse.data.id;
|
||||
const providerAccountId = values.accountName;
|
||||
|
||||
void logEvent('GCP Integration: Account created', {
|
||||
id: cloudIntegrationId,
|
||||
});
|
||||
|
||||
// Step 2: mimic the agent by checking in from the frontend (manual flow).
|
||||
await checkIn({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
providerAccountId,
|
||||
cloudIntegrationId,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
|
||||
} catch (error) {
|
||||
// Surface the backend's message inline in the drawer instead of a
|
||||
// generic failure string.
|
||||
const message = toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
fallbackMessage,
|
||||
'Failed to connect GCP account',
|
||||
).getErrorMessage();
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleConnected = useCallback(
|
||||
({
|
||||
cloudIntegrationId,
|
||||
providerAccountId,
|
||||
}: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string | null;
|
||||
}): void => {
|
||||
void logEvent('GCP Integration: Account connected', {
|
||||
cloudIntegrationId,
|
||||
providerAccountId,
|
||||
});
|
||||
toast.success('GCP account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[onClose, queryClient],
|
||||
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams: connectionParams?.data as
|
||||
| CloudintegrationtypesCredentialsDTO
|
||||
| undefined,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
setSubmitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useGcpAccountSetup } from './useGcpAccountSetup';
|
||||
|
||||
interface UseManualAccountSetupProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseManualAccountSetup {
|
||||
isLoading: boolean;
|
||||
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
submitError: string | null;
|
||||
clearSubmitError: () => void;
|
||||
}
|
||||
|
||||
export function useManualAccountSetup({
|
||||
onClose,
|
||||
}: UseManualAccountSetupProps): UseManualAccountSetup {
|
||||
const {
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
} = useGcpAccountSetup({ onClose });
|
||||
|
||||
const { mutateAsync: checkIn } = useAgentCheckIn();
|
||||
|
||||
const connectAccount = useCallback(
|
||||
(values: GcpSetupFormValues): Promise<void> =>
|
||||
runSubmit(async () => {
|
||||
// Step 1: create the integration account.
|
||||
const account = await createGcpAccount(
|
||||
{
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
projectIds: values.projectIds || [],
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
const cloudIntegrationId = account.id;
|
||||
const providerAccountId = values.accountName;
|
||||
|
||||
void logEvent('GCP Integration: Account created', {
|
||||
id: cloudIntegrationId,
|
||||
});
|
||||
|
||||
// Step 2: mimic the agent by checking in from the frontend (manual flow).
|
||||
await checkIn({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
providerAccountId,
|
||||
cloudIntegrationId,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
handleConnected({ cloudIntegrationId, providerAccountId });
|
||||
}, 'Failed to connect GCP account'),
|
||||
[checkIn, createGcpAccount, handleConnected, runSubmit],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
type CloudintegrationtypesPostableAccountDTO,
|
||||
type CloudintegrationtypesUpdatableServiceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
@@ -22,14 +18,9 @@ import {
|
||||
CLOUD_PROVIDERS,
|
||||
CLOUD_SERVICE_CAP,
|
||||
type CloudProvider,
|
||||
GCP_CONNECTION_METHODS,
|
||||
MUTATION_STATES,
|
||||
type MutationState,
|
||||
NEW_ACCOUNT_CHECK_INS,
|
||||
type NewAccountCheckIn,
|
||||
createdAccountResponse,
|
||||
newAccountId,
|
||||
newAccountResponse,
|
||||
BUILT_IN_INTEGRATION_NOT_FOUND,
|
||||
credentialsResponse,
|
||||
seedServiceSignals,
|
||||
@@ -45,11 +36,6 @@ import {
|
||||
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
|
||||
|
||||
const FAILED_REQUEST = {
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'Request failed' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers a mutation by the control's state: never, with a failure, or with the
|
||||
* success body the caller builds.
|
||||
@@ -62,33 +48,16 @@ const mutationResolver =
|
||||
}
|
||||
|
||||
return state === 'error'
|
||||
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
|
||||
? res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'Request failed' },
|
||||
}),
|
||||
)
|
||||
: ok(req, res, ctx, ...rest);
|
||||
};
|
||||
|
||||
/**
|
||||
* The account the connect flow just created answers by the check-in control;
|
||||
* every other account is the page's data.
|
||||
*/
|
||||
const accountResolver =
|
||||
(
|
||||
provider: CloudProvider,
|
||||
checkIn: NewAccountCheckIn,
|
||||
existing: MockResolver,
|
||||
): MockResolver =>
|
||||
(req, res, ctx, ...rest) => {
|
||||
if (String(req.params.id) !== newAccountId(provider)) {
|
||||
return existing(req, res, ctx, ...rest);
|
||||
}
|
||||
|
||||
return checkIn === 'error'
|
||||
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
|
||||
: res(
|
||||
ctx.status(200),
|
||||
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
|
||||
);
|
||||
};
|
||||
|
||||
const PROVIDER = 'Cloud integration · provider';
|
||||
const MUTATIONS = 'Cloud integration · mutations';
|
||||
const SERVICES = 'Cloud integration · services';
|
||||
@@ -102,17 +71,6 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
options: CLOUD_PROVIDERS,
|
||||
value: 'aws',
|
||||
}),
|
||||
gcpConnectionMethod:
|
||||
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
|
||||
'GCP connection method',
|
||||
{
|
||||
group: PROVIDER,
|
||||
description:
|
||||
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
|
||||
options: GCP_CONNECTION_METHODS,
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
},
|
||||
),
|
||||
accounts: countControl('Connected accounts', {
|
||||
group: PROVIDER,
|
||||
description:
|
||||
@@ -141,13 +99,6 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
options: MUTATION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
|
||||
group: MUTATIONS,
|
||||
description:
|
||||
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
|
||||
options: NEW_ACCOUNT_CHECK_INS,
|
||||
value: 'checked-in',
|
||||
}),
|
||||
accountUpdate: choiceControl<MutationState>('Account settings update', {
|
||||
group: MUTATIONS,
|
||||
description:
|
||||
@@ -221,19 +172,9 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
mutationResolver(values.accountCreate, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
|
||||
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json(
|
||||
createdAccountResponse(
|
||||
values.provider,
|
||||
body.config?.gcp?.connectionMethod,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
mutationResolver(values.accountCreate, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
@@ -241,41 +182,21 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
mutationResolver(values.accountUpdate, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
accountResponse(
|
||||
values.provider,
|
||||
String(req.params.id),
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
ctx.json(accountResponse(values.provider, String(req.params.id))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
|
||||
accountResolver(
|
||||
values.provider,
|
||||
values.newAccountCheckIn,
|
||||
response.json((req) =>
|
||||
accountResponse(
|
||||
values.provider,
|
||||
String(req.params.id),
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
response.json((req) =>
|
||||
accountResponse(values.provider, String(req.params.id)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
response.json(() =>
|
||||
accountsResponse(
|
||||
values.provider,
|
||||
values.accounts,
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
response.json(() => accountsResponse(values.provider, values.accounts)),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
@@ -343,17 +264,6 @@ export const connectAzureAccount = async (
|
||||
);
|
||||
};
|
||||
|
||||
const addGcpProjectId = async (
|
||||
testId: string,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(screen.getByTestId(testId));
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Type a project ID and press Enter'),
|
||||
`${projectId}{Enter}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const connectGcpAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
@@ -368,40 +278,14 @@ export const connectGcpAccount = async (
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await addGcpProjectId('gcp-project-ids-select', 'project-a');
|
||||
await userEvent.type(
|
||||
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
|
||||
'project-a,',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
};
|
||||
|
||||
export const openGcpAgentSetup = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openConnectFlow(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'gcp-connection-method-agent',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const generateGcpAgentCommand = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openGcpAgentSetup(canvasElement);
|
||||
await userEvent.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'signoz-prod-4471',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
|
||||
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
};
|
||||
|
||||
export const openEditAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/edit account/i,
|
||||
@@ -424,17 +308,6 @@ export const updateAwsAccount = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const updateGcpProjects = async (
|
||||
canvasElement: HTMLElement,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /update changes/i }),
|
||||
);
|
||||
};
|
||||
|
||||
export const updateTaggedAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
tag: string,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -13,13 +12,9 @@ import {
|
||||
connectAzureAccount,
|
||||
connectGcpAccount,
|
||||
expectToast,
|
||||
generateGcpAgentCommand,
|
||||
openConnectFlow,
|
||||
openEditAccount,
|
||||
openGcpAgentSetup,
|
||||
untilLoaded,
|
||||
updateAwsAccount,
|
||||
updateGcpProjects,
|
||||
updateTaggedAccount,
|
||||
} from './CloudIntegration.stories.mocks';
|
||||
|
||||
@@ -256,111 +251,6 @@ export const GcpRegionOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The GCP connect drawer on its agent connection method: only the deployment
|
||||
* project, region and credentials, since the agent monitors the project it is
|
||||
* deployed in.
|
||||
*/
|
||||
export const GcpAgentSetup: Story = {
|
||||
args: { provider: 'gcp', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openGcpAgentSetup(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The setup command the agent connection generates, with the drawer waiting for
|
||||
* the agent to check in. Set New account check-in to
|
||||
* `checked-in` or `error` to see it connect or fail.
|
||||
*/
|
||||
export const GcpAgentCommand: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The agent checks in on the first poll, so the drawer closes with a toast. */
|
||||
export const GcpAgentConnectedToast: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
if (args.newAccountCheckIn === 'checked-in') {
|
||||
await expectToast(/gcp account connected successfully/i);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The connection poll fails, so the drawer reports it could not connect and
|
||||
* offers Check Again.
|
||||
*/
|
||||
export const GcpAgentConnectionError: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await screen.findByTestId(
|
||||
'gcp-agent-connection-error',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Leaving the drawer while the agent has not connected yet, which asks first
|
||||
* because closing means starting the installation again.
|
||||
*/
|
||||
export const GcpAgentCloseConfirm: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings of a GCP account connected through the agent: read-only, since
|
||||
* the backend rejects updates to agent accounts.
|
||||
*/
|
||||
export const GcpAgentAccountSettings: Story = {
|
||||
args: {
|
||||
provider: 'gcp',
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await screen.findByTestId(
|
||||
'gcp-agent-monitored-projects',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Disconnecting an agent account, which names what is left to clean up. */
|
||||
export const GcpAgentDisconnect: Story = {
|
||||
args: {
|
||||
provider: 'gcp',
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(
|
||||
/signoz-integration-bootstrap Infrastructure/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Saving the regions of a connected AWS account. Set Account settings update
|
||||
* to `error` for the failure toast.
|
||||
@@ -411,7 +301,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
|
||||
export const GcpAccountSettingsUpdatedToast: Story = {
|
||||
args: { provider: 'gcp' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await updateGcpProjects(canvasElement, 'extra-project');
|
||||
await updateTaggedAccount(canvasElement, 'extra-project');
|
||||
if (args.accountUpdate === 'success') {
|
||||
await expectToast(/account settings updated successfully/i);
|
||||
}
|
||||
@@ -422,7 +312,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
|
||||
export const GcpAccountSettingsUpdateErrorToast: Story = {
|
||||
args: { provider: 'gcp', accountUpdate: 'error' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await updateGcpProjects(canvasElement, 'extra-project');
|
||||
await updateTaggedAccount(canvasElement, 'extra-project');
|
||||
if (args.accountUpdate === 'error') {
|
||||
await expectToast(/failed to update account settings/i);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
CloudintegrationtypesAccountConfigDTO,
|
||||
CloudintegrationtypesAccountDTO,
|
||||
@@ -24,19 +23,6 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
|
||||
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
|
||||
export type MutationState = (typeof MUTATION_STATES)[number];
|
||||
|
||||
export const GCP_CONNECTION_METHODS = [
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
] as const;
|
||||
|
||||
/** How the poll for the account the connect flow just created answers. */
|
||||
export const NEW_ACCOUNT_CHECK_INS = [
|
||||
'checked-in',
|
||||
'pending',
|
||||
'error',
|
||||
] as const;
|
||||
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
|
||||
|
||||
interface CloudServiceSeed {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -280,7 +266,6 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
|
||||
|
||||
const accountConfig = (
|
||||
provider: CloudProvider,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CloudintegrationtypesAccountConfigDTO => {
|
||||
if (provider === 'aws') {
|
||||
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
|
||||
@@ -295,23 +280,8 @@ const accountConfig = (
|
||||
};
|
||||
}
|
||||
|
||||
// The backend only accepts the deployment project as an agent account's scope.
|
||||
if (
|
||||
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
|
||||
) {
|
||||
return {
|
||||
gcp: {
|
||||
connectionMethod: gcpConnectionMethod,
|
||||
deploymentRegion: 'us-central1',
|
||||
deploymentProjectId: 'signoz-prod-4471',
|
||||
projectIds: ['signoz-prod-4471'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
gcp: {
|
||||
connectionMethod: gcpConnectionMethod,
|
||||
deploymentRegion: 'us-central1',
|
||||
deploymentProjectId: 'signoz-prod-4471',
|
||||
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
|
||||
@@ -322,13 +292,12 @@ const accountConfig = (
|
||||
const account = (
|
||||
provider: CloudProvider,
|
||||
index: number,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CloudintegrationtypesAccountDTO => ({
|
||||
id: `${provider}-account-${index + 1}`,
|
||||
orgId: 'story-org',
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider, gcpConnectionMethod),
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
@@ -342,12 +311,11 @@ const account = (
|
||||
export const accountsResponse = (
|
||||
provider: CloudProvider,
|
||||
count: number,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): ListAccounts200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
accounts: Array.from({ length: count }, (_, index) =>
|
||||
account(provider, index, gcpConnectionMethod),
|
||||
account(provider, index),
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -355,52 +323,11 @@ export const accountsResponse = (
|
||||
export const accountResponse = (
|
||||
provider: CloudProvider,
|
||||
id: string,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): GetAccount200 => {
|
||||
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
|
||||
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: account(provider, index, gcpConnectionMethod),
|
||||
};
|
||||
};
|
||||
|
||||
/** The backend's command template, filled in for the story's project and region. */
|
||||
const GCP_AGENT_COMMAND = {
|
||||
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
|
||||
--region 'us-central1' \\
|
||||
--gcp-project 'signoz-prod-4471' \\
|
||||
--integration-account-id 'gcp-account-new' \\
|
||||
--signoz-api-url 'https://story.us.signoz.cloud' \\
|
||||
--signoz-api-key 'story-api-key-8b7d02' \\
|
||||
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
|
||||
--ingestion-key 'story-ingestion-key-2f9c41' \\
|
||||
--agent-version 'v0.0.8'`,
|
||||
};
|
||||
|
||||
export const newAccountId = (provider: CloudProvider): string =>
|
||||
`${provider}-account-new`;
|
||||
|
||||
/**
|
||||
* The account the connect flow just created, as its connection poll sees it. Only
|
||||
* GCP's agent connection polls, so a GCP account here is an agent one.
|
||||
*/
|
||||
export const newAccountResponse = (
|
||||
provider: CloudProvider,
|
||||
isCheckedIn: boolean,
|
||||
): GetAccount200 => {
|
||||
const created = {
|
||||
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
|
||||
id: newAccountId(provider),
|
||||
};
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: isCheckedIn
|
||||
? created
|
||||
: { ...created, providerAccountId: null, agentReport: null },
|
||||
};
|
||||
return { status: 'success', data: account(provider, index) };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -614,16 +541,14 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
|
||||
|
||||
/**
|
||||
* The connection URL is what the setup flow opens in a new tab, which the story
|
||||
* reports through the navigation overlay rather than following. GCP returns its
|
||||
* setup command only for an agent connection; a manual one gets none.
|
||||
* reports through the navigation overlay rather than following.
|
||||
*/
|
||||
export const createdAccountResponse = (
|
||||
provider: CloudProvider,
|
||||
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CreateAccount201 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: newAccountId(provider),
|
||||
id: `${provider}-account-new`,
|
||||
connectionArtifact: {
|
||||
aws: {
|
||||
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
|
||||
@@ -634,10 +559,6 @@ export const createdAccountResponse = (
|
||||
cloudPowerShellCommand:
|
||||
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
|
||||
},
|
||||
...(gcpConnectionMethod ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
|
||||
gcp: GCP_AGENT_COMMAND,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('SettingsPage nav sections', () => {
|
||||
'settings-page-sidenav',
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'billing',
|
||||
'roles',
|
||||
'members',
|
||||
@@ -55,12 +56,6 @@ describe('SettingsPage nav sections', () => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "notification-channels" element', () => {
|
||||
expect(
|
||||
screen.queryByTestId('notification-channels'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['Identity & Access', 'Authentication'])(
|
||||
'renders "%s" section title',
|
||||
(text) => {
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
|
||||
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
|
||||
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
|
||||
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
|
||||
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
|
||||
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
|
||||
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
|
||||
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
|
||||
|
||||
export const infoData = [
|
||||
{
|
||||
id: 'infoBlock-1',
|
||||
@@ -52,7 +43,7 @@ export const enterpriseGradeValuesData = [
|
||||
export const customerStoriesData = [
|
||||
{
|
||||
key: 'story-subomi-oluwalana',
|
||||
avatar: subomiOluwalanaUrl,
|
||||
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
|
||||
personName: 'Subomi Oluwalana',
|
||||
role: 'Founder & CEO at Convoy',
|
||||
customerName: 'Convoy',
|
||||
@@ -63,7 +54,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-dhruv-garg',
|
||||
avatar: dhruvGargUrl,
|
||||
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
|
||||
personName: 'Dhruv Garg',
|
||||
role: 'Tech Lead at Nudge',
|
||||
customerName: 'Nudge',
|
||||
@@ -74,7 +65,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-vivek-bhakta',
|
||||
avatar: vivekBhaktaUrl,
|
||||
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
|
||||
personName: 'Vivek Bhakta',
|
||||
role: 'CTO at Wombo AI',
|
||||
customerName: 'Wombo AI',
|
||||
@@ -84,7 +75,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-pranay-narang',
|
||||
avatar: pranayNarangUrl,
|
||||
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
|
||||
personName: 'Pranay Narang',
|
||||
role: 'Engineering at Azodha',
|
||||
customerName: 'Azodha',
|
||||
@@ -94,7 +85,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-Sheheryar-Sewani',
|
||||
avatar: sheheryarSewaniUrl,
|
||||
avatar: 'https://signoz.io/img/users/shey.webp',
|
||||
personName: 'Sheheryar Sewani',
|
||||
role: 'Seasoned Rails Dev & Founder',
|
||||
customerName: '',
|
||||
@@ -105,7 +96,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-daniel-schell',
|
||||
avatar: danielSchellUrl,
|
||||
avatar: 'https://signoz.io/img/users/daniel.webp',
|
||||
personName: 'Daniel Schell',
|
||||
role: 'Founder & CTO at Airlockdigital',
|
||||
customerName: 'Airlockdigital',
|
||||
@@ -115,7 +106,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'c-story-6',
|
||||
avatar: goFrendiGunawanUrl,
|
||||
avatar: 'https://signoz.io/img/users/go-frendi.webp',
|
||||
personName: 'Go Frendi Gunawan',
|
||||
role: 'Data Engineer at Ctlyst.id',
|
||||
customerName: 'Ctlyst.id',
|
||||
@@ -125,7 +116,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-anselm-eickhoff',
|
||||
avatar: anselmEickhoffUrl,
|
||||
avatar: 'https://signoz.io/img/users/anselm.jpg',
|
||||
personName: 'Anselm Eickhoff',
|
||||
role: 'Software Architect',
|
||||
customerName: '',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Event, StackFrame } from '@sentry/react';
|
||||
|
||||
import { hasNoAppFrames } from '../sentry';
|
||||
|
||||
const PAGE_URL = 'https://acme.signoz.cloud/dashboard/01a081e1';
|
||||
const APP_CHUNK_URL = 'https://acme.signoz.cloud/assets/index-CcNiYOcv.js';
|
||||
|
||||
const errorEvent = (frames: StackFrame[]): Event => ({
|
||||
exception: {
|
||||
values: [{ type: 'SyntaxError', stacktrace: { frames } }],
|
||||
},
|
||||
});
|
||||
|
||||
describe('hasNoAppFrames', () => {
|
||||
it('is true for a script injected into the page', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
errorEvent([
|
||||
{ filename: PAGE_URL, lineno: 1, colno: 30, function: 'global code' },
|
||||
{ filename: '[native code]', function: 'eval' },
|
||||
{ filename: '[native code]', function: 'append' },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a browser extension', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
errorEvent([
|
||||
{ filename: 'chrome-extension://abc/content.js', lineno: 4, colno: 12 },
|
||||
]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when any frame comes from an app chunk', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
errorEvent([
|
||||
{ filename: PAGE_URL, lineno: 1, colno: 30 },
|
||||
{ filename: APP_CHUNK_URL, lineno: 2, colno: 4410 },
|
||||
]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an event without an exception', () => {
|
||||
expect(
|
||||
hasNoAppFrames({ message: 'Received unknown severity on Alert Details' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when no frame has a position', () => {
|
||||
expect(
|
||||
hasNoAppFrames(
|
||||
errorEvent([{ filename: '[native code]', function: 'eval' }]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Event } from '@sentry/react';
|
||||
|
||||
const VITE_ASSETS_PATH = '/assets/';
|
||||
|
||||
export function hasNoAppFrames(event: Event): boolean {
|
||||
const frames = (event.exception?.values ?? [])
|
||||
.flatMap((value) => value.stacktrace?.frames ?? [])
|
||||
.filter(
|
||||
(frame) => frame.filename && (frame.lineno != null || frame.colno != null),
|
||||
);
|
||||
|
||||
return (
|
||||
frames.length > 0 &&
|
||||
!frames.some((frame) => frame.filename?.includes(VITE_ASSETS_PATH))
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.16",
|
||||
Version: "v0.0.15",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/exporttypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -25,10 +26,11 @@ import (
|
||||
|
||||
type handler struct {
|
||||
module rawdataexport.Module
|
||||
budget querier.Budget
|
||||
}
|
||||
|
||||
func NewHandler(module rawdataexport.Module) rawdataexport.Handler {
|
||||
return &handler{module: module}
|
||||
func NewHandler(module rawdataexport.Module, budget querier.Budget) rawdataexport.Handler {
|
||||
return &handler{module: module, budget: budget}
|
||||
}
|
||||
|
||||
func (handler *handler) ExportRawData(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,6 +52,11 @@ func (handler *handler) ExportRawData(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.budget.AdmitTimeRange(queryRangeRequest.Start, queryRangeRequest.End); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateAndApplyDefaultExportLimits(queryRangeRequest.CompositeQuery.Queries); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -33,10 +33,11 @@ type handler struct {
|
||||
set factory.ProviderSettings
|
||||
analytics analytics.Analytics
|
||||
querier Querier
|
||||
budget Budget
|
||||
}
|
||||
|
||||
func NewHandler(set factory.ProviderSettings, querier Querier, analytics analytics.Analytics) Handler {
|
||||
return &handler{set: set, querier: querier, analytics: analytics}
|
||||
func NewHandler(set factory.ProviderSettings, querier Querier, analytics analytics.Analytics, budget Budget) Handler {
|
||||
return &handler{set: set, querier: querier, analytics: analytics, budget: budget}
|
||||
}
|
||||
|
||||
func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -58,12 +59,16 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the query request
|
||||
if err := queryRangeRequest.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.budget.AdmitTimeRange(queryRangeRequest.Start, queryRangeRequest.End); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
@@ -140,7 +145,9 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
|
||||
}
|
||||
// create the v5 request param
|
||||
queryRangeRequest := qbtypes.QueryRangeRequest{
|
||||
Start: start,
|
||||
// Coerce the window to epoch milliseconds up front (End may be 0 for the
|
||||
// open-ended stream, which ToMilliSecs leaves untouched).
|
||||
Start: qbtypes.ToMilliSecs(start),
|
||||
RequestType: qbtypes.RequestTypeRawStream,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
|
||||
32
pkg/querier/budget.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// Budget is the admission policy applied to client-supplied query windows at the
|
||||
// API edge. It is deployment capacity, not well-formedness: a window it rejects
|
||||
// is still a valid QueryRangeRequest, which is why it is configurable and why
|
||||
// in-process callers — whose windows are derived, not client-supplied — never
|
||||
// consult it.
|
||||
type Budget struct {
|
||||
maxTimeRange time.Duration
|
||||
}
|
||||
|
||||
func NewBudget(config Config) Budget {
|
||||
return Budget{maxTimeRange: config.MaxTimeRange}
|
||||
}
|
||||
|
||||
// AdmitTimeRange rejects a window wider than the configured maximum. Both bounds
|
||||
// are epoch milliseconds; a zero maximum disables the limit.
|
||||
func (budget Budget) AdmitTimeRange(startMS, endMS uint64) error {
|
||||
if budget.maxTimeRange <= 0 || endMS <= startMS {
|
||||
return nil
|
||||
}
|
||||
if time.Duration(endMS-startMS)*time.Millisecond > budget.maxTimeRange {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "query time range must not exceed %d days", int64(budget.maxTimeRange/(24*time.Hour)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
const DefaultMaxConcurrentQueries = 8
|
||||
|
||||
const DefaultMaxTimeRange = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// Config represents the configuration for the querier.
|
||||
type Config struct {
|
||||
// CacheTTL is the TTL for cached query results
|
||||
@@ -20,6 +22,8 @@ type Config struct {
|
||||
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
|
||||
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
|
||||
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
|
||||
// MaxTimeRange is the maximum allowed span between start and end of a query range request; 0 disables the limit.
|
||||
MaxTimeRange time.Duration `yaml:"max_time_range" mapstructure:"max_time_range"`
|
||||
|
||||
// Keys sit under querier.skip_resource_fingerprint.
|
||||
statementbuilder.Config `mapstructure:",squash" yaml:",squash"`
|
||||
@@ -37,6 +41,7 @@ func newConfig() factory.Config {
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
|
||||
LogTraceIDWindowPadding: 5 * time.Minute,
|
||||
MaxTimeRange: DefaultMaxTimeRange,
|
||||
Config: statementbuilder.NewConfig(),
|
||||
}
|
||||
}
|
||||
@@ -55,6 +60,9 @@ func (c Config) Validate() error {
|
||||
if c.LogTraceIDWindowPadding < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
|
||||
}
|
||||
if c.MaxTimeRange < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_time_range must not be negative, got %v", c.MaxTimeRange)
|
||||
}
|
||||
// Embedded Validate is shadowed by this one; call it explicitly.
|
||||
if err := c.Config.Validate(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,9 +20,9 @@ func makeBuckets(start, end uint64) []tsRange {
|
||||
curEnd := endNS
|
||||
|
||||
for {
|
||||
curStart := curEnd - bucket
|
||||
if curStart < startNS {
|
||||
curStart = startNS
|
||||
curStart := startNS
|
||||
if curEnd-startNS > bucket {
|
||||
curStart = curEnd - bucket
|
||||
}
|
||||
out = append(out, tsRange{fromNS: curStart, toNS: curEnd})
|
||||
|
||||
|
||||
@@ -111,12 +111,6 @@ func New(
|
||||
|
||||
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
|
||||
|
||||
// Coerce the window to epoch milliseconds up front so every downstream
|
||||
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
|
||||
// safely assume ms regardless of the resolution the caller sent.
|
||||
req.Start = querybuilder.ToMilliSecs(req.Start)
|
||||
req.End = querybuilder.ToMilliSecs(req.End)
|
||||
|
||||
event := &qbtypes.QBEvent{
|
||||
Version: "v5",
|
||||
NumberOfQueries: len(req.CompositeQuery.Queries),
|
||||
@@ -534,11 +528,6 @@ func familyTemporality(temporalities map[string]metrictypes.Temporality, names [
|
||||
|
||||
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
|
||||
|
||||
// Coerce the window to epoch milliseconds up front (End may be 0 for the
|
||||
// open-ended stream, which ToMilliSecs leaves untouched).
|
||||
req.Start = querybuilder.ToMilliSecs(req.Start)
|
||||
req.End = querybuilder.ToMilliSecs(req.End)
|
||||
|
||||
event := &qbtypes.QBEvent{
|
||||
Version: "v5",
|
||||
NumberOfQueries: len(req.CompositeQuery.Queries),
|
||||
|
||||
@@ -33,28 +33,6 @@ func ToNanoSecs(epoch uint64) uint64 {
|
||||
return temp * uint64(math.Pow(10, float64(19-count)))
|
||||
}
|
||||
|
||||
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
|
||||
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
|
||||
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
|
||||
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
|
||||
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
|
||||
func ToMilliSecs(epoch uint64) uint64 {
|
||||
if epoch == 0 {
|
||||
return 0
|
||||
}
|
||||
temp := epoch
|
||||
count := 0
|
||||
for epoch != 0 {
|
||||
epoch /= 10
|
||||
count++
|
||||
}
|
||||
const msDigits = 13
|
||||
if count < msDigits {
|
||||
return temp * uint64(math.Pow(10, float64(msDigits-count)))
|
||||
}
|
||||
return temp / uint64(math.Pow(10, float64(count-msDigits)))
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
|
||||
// That would make graph look nice but "nice" but should be less important than the usefulness.
|
||||
func RecommendedStepInterval(start, end uint64) uint64 {
|
||||
|
||||
@@ -60,51 +60,3 @@ func TestToNanoSecs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToMilliSecs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
epoch uint64
|
||||
expected uint64
|
||||
}{
|
||||
{
|
||||
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
|
||||
epoch: 1672531200, // seconds
|
||||
expected: 1672531200000, // * 10^3
|
||||
},
|
||||
{
|
||||
name: "13-digit Unix timestamp (milliseconds) - already ms",
|
||||
epoch: 1672531200000,
|
||||
expected: 1672531200000, // unchanged
|
||||
},
|
||||
{
|
||||
name: "16-digit Unix timestamp (microseconds)",
|
||||
epoch: 1672531200000000, // microseconds
|
||||
expected: 1672531200000, // / 10^3
|
||||
},
|
||||
{
|
||||
name: "19-digit Unix timestamp (nanoseconds)",
|
||||
epoch: 1672531200000000000, // nanoseconds
|
||||
expected: 1672531200000, // / 10^6
|
||||
},
|
||||
{
|
||||
name: "Unix epoch start - zero is unchanged",
|
||||
epoch: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
|
||||
epoch: 1716638400,
|
||||
expected: 1716638400000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ToMilliSecs(tt.epoch)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ type Handlers struct {
|
||||
|
||||
func NewHandlers(
|
||||
modules Modules,
|
||||
config Config,
|
||||
providerSettings factory.ProviderSettings,
|
||||
analytics analytics.Analytics,
|
||||
querierHandler querier.Handler,
|
||||
@@ -120,7 +121,7 @@ func NewHandlers(
|
||||
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings, authz),
|
||||
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
|
||||
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
|
||||
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport, querier.NewBudget(config.Querier)),
|
||||
Services: implservices.NewHandler(modules.Services),
|
||||
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
|
||||
MetricReductionRule: implmetricreductionrule.NewHandler(modules.MetricReductionRule),
|
||||
|
||||
@@ -67,9 +67,9 @@ func TestNewHandlers(t *testing.T) {
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), spanMapperRegistry, providerSettings)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil, querier.Budget{})
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
|
||||
handlers := NewHandlers(modules, Config{}, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
f := reflectVal.Field(i)
|
||||
|
||||
@@ -264,7 +264,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -615,7 +615,7 @@ func New(
|
||||
registryHandler := factory.NewHandler(registry)
|
||||
subscriptionService := subscriptionCallback(zeus, licensing)
|
||||
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, subscriptionService, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator)
|
||||
handlers := NewHandlers(modules, config, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, subscriptionService, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator)
|
||||
|
||||
// Initialize the API server (after registry so it can access service health)
|
||||
apiserverInstance, err := factory.NewProviderFromNamedMap(
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addGCPConnectionMethod struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type gcpConnectionMethodMigrationRow struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Config string `bun:"config"`
|
||||
}
|
||||
|
||||
func NewAddGCPConnectionMethodFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_gcp_connection_method"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addGCPConnectionMethod{logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var accounts []*gcpConnectionMethodMigrationRow
|
||||
if err := tx.NewSelect().
|
||||
Model(&accounts).
|
||||
Where("provider = ?", "gcp").
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
// Preserve config fields that are not known to this migration.
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(account.Config), &config); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with unreadable config", slog.String("account_id", account.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if config == nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with null config", slog.String("account_id", account.ID))
|
||||
continue
|
||||
}
|
||||
if _, exists := config["connectionMethod"]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
config["connectionMethod"] = json.RawMessage(`"manual"`)
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*gcpConnectionMethodMigrationRow)(nil)).
|
||||
Set("config = ?", string(encoded)).
|
||||
Where("id = ?", account.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
}
|
||||
account.Config.Azure = azureConfig
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := &GCPAccountConfig{ConnectionMethod: GCPConnectionMethodManual}
|
||||
gcpConfig := new(GCPAccountConfig)
|
||||
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,16 +240,25 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
|
||||
}
|
||||
|
||||
gcpConfig := &GCPAccountConfig{
|
||||
ConnectionMethod: config.GCP.ConnectionMethod,
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
if config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
if err := gcpConfig.ErrIfInvalid(); err != nil {
|
||||
|
||||
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AccountConfig{GCP: gcpConfig}, nil
|
||||
|
||||
if len(config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
GCP: &GCPAccountConfig{
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -292,12 +301,12 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateGCPProjectIDs(config.Config.GCP.ProjectIDs); err != nil {
|
||||
return nil, err
|
||||
if len(config.Config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
if err := validateGCPDeploymentProjectID(config.Config.GCP.DeploymentProjectID); err != nil {
|
||||
return nil, err
|
||||
if config.Config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
@@ -346,13 +355,6 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
|
||||
}
|
||||
|
||||
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
|
||||
if provider == CloudProviderTypeGCP {
|
||||
if account.Config.GCP.ConnectionMethod == GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP agent account configuration cannot be updated")
|
||||
}
|
||||
config.GCP.ConnectionMethod = account.Config.GCP.ConnectionMethod
|
||||
}
|
||||
|
||||
// deployment region can not be updated once set for Azure
|
||||
if provider == CloudProviderTypeAzure {
|
||||
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
|
||||
@@ -364,18 +366,6 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) ErrIfInvalidProviderAccountID(providerAccountID string) error {
|
||||
if account.Provider != CloudProviderTypeGCP || account.Config.GCP.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if providerAccountID != account.Config.GCP.DeploymentProjectID {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "providerAccountId must match deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
|
||||
account.ProviderAccountID = providerAccountID
|
||||
account.AgentReport = agentReport
|
||||
|
||||
@@ -1,69 +1,32 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
|
||||
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
|
||||
|
||||
foundryScriptURL = "https://signoz.io/foundry.sh"
|
||||
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}).Parse(gcpCLITemplateStr()))
|
||||
)
|
||||
|
||||
type GCPConnectionMethod struct{ valuer.String }
|
||||
|
||||
func (GCPConnectionMethod) Enum() []any {
|
||||
return []any{GCPConnectionMethodManual, GCPConnectionMethodAgent}
|
||||
}
|
||||
|
||||
type GCPAccountConfig struct {
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// Agent accounts store exactly the deployment project; manual accounts may monitor multiple projects.
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Project ID where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPPostableAccountConfig struct {
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// Agent setup requires exactly DeploymentProjectID; manual setup may select multiple projects.
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
type GCPPostableAccountConfig = GCPAccountConfig
|
||||
|
||||
// UpdatableGCPAccountConfig contains settings for manual integrations only.
|
||||
type UpdatableGCPAccountConfig struct {
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Compute service region where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true"`
|
||||
}
|
||||
|
||||
type GCPConnectionArtifact struct {
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
type GCPConnectionArtifact struct{}
|
||||
|
||||
type GCPIntegrationConfig struct {
|
||||
GCPAccountConfig
|
||||
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
|
||||
}
|
||||
type GCPIntegrationConfig struct{}
|
||||
|
||||
type GCPTelemetryCollectionStrategy struct {
|
||||
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"true" nullable:"true"`
|
||||
Logs *GCPLogsCollectionStrategy `json:"logs" required:"true" nullable:"true"`
|
||||
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
|
||||
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPMetricsCollectionStrategy struct {
|
||||
@@ -87,120 +50,3 @@ type GCPServiceLogsConfig struct {
|
||||
type GCPServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
func (config *GCPAccountConfig) ErrIfInvalid() error {
|
||||
if config.ConnectionMethod.IsZero() {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "connectionMethod is required for GCP provider")
|
||||
}
|
||||
if config.ConnectionMethod != GCPConnectionMethodManual && config.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "unsupported GCP connectionMethod: %q", config.ConnectionMethod.StringValue())
|
||||
}
|
||||
if err := validateGCPDeploymentProjectID(config.DeploymentProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPRegion(config.DeploymentRegion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPProjectIDs(config.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.ConnectionMethod == GCPConnectionMethodAgent && (len(config.ProjectIDs) != 1 || config.ProjectIDs[0] != config.DeploymentProjectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain only deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
|
||||
data := gcpTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
if err := gcpCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GCPConnectionArtifact{
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGCPIntegrationConfig sorts and deduplicates the input slices in place and formats sink filters for joining with OR.
|
||||
func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters []string) *GCPIntegrationConfig {
|
||||
// Parentheses preserve each service filter's meaning when combined with OR.
|
||||
for i, filter := range sinkFilters {
|
||||
sinkFilters[i] = "(" + strings.TrimSpace(filter) + ")"
|
||||
}
|
||||
|
||||
// Keep the generated config stable and avoid collecting shared metrics more than once.
|
||||
slices.Sort(metricNames)
|
||||
metricNames = slices.Compact(metricNames)
|
||||
slices.Sort(sinkFilters)
|
||||
sinkFilters = slices.Compact(sinkFilters)
|
||||
|
||||
strategy := new(GCPTelemetryCollectionStrategy)
|
||||
if len(metricNames) > 0 {
|
||||
strategy.Metrics = &GCPMetricsCollectionStrategy{MetricNames: metricNames}
|
||||
}
|
||||
if len(sinkFilters) > 0 {
|
||||
strategy.Logs = &GCPLogsCollectionStrategy{SinkFilter: strings.Join(sinkFilters, " OR ")}
|
||||
}
|
||||
|
||||
return &GCPIntegrationConfig{
|
||||
GCPAccountConfig: *config,
|
||||
TelemetryCollectionStrategy: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
type gcpTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
ProjectID string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
}
|
||||
|
||||
func gcpCLITemplateStr() string {
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration gcp terraform \
|
||||
--region {{.Region | shellQuote}} \
|
||||
--gcp-project {{.ProjectID | shellQuote}} \
|
||||
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
|
||||
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
|
||||
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
|
||||
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
|
||||
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
|
||||
--agent-version {{.AgentVersion | shellQuote}}`
|
||||
}
|
||||
|
||||
func validateGCPDeploymentProjectID(projectID string) error {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId is required for GCP provider")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId cannot contain leading or trailing whitespace")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGCPProjectIDs(projectIDs []string) error {
|
||||
if len(projectIDs) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain at least one project ID for GCP provider")
|
||||
}
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain an empty or whitespace-only project ID")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain leading or trailing whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -673,6 +673,12 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
// Copy the decoded values back to the original struct
|
||||
*r = QueryRangeRequest(temp)
|
||||
|
||||
// Coerce the window to epoch milliseconds up front so every downstream
|
||||
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
|
||||
// safely assume ms regardless of the resolution the caller sent.
|
||||
r.Start = ToMilliSecs(r.Start)
|
||||
r.End = ToMilliSecs(r.End)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
25
pkg/types/querybuildertypes/querybuildertypesv5/time.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import "math"
|
||||
|
||||
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
|
||||
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
|
||||
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
|
||||
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
|
||||
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
|
||||
func ToMilliSecs(epoch uint64) uint64 {
|
||||
if epoch == 0 {
|
||||
return 0
|
||||
}
|
||||
temp := epoch
|
||||
count := 0
|
||||
for epoch != 0 {
|
||||
epoch /= 10
|
||||
count++
|
||||
}
|
||||
const msDigits = 13
|
||||
if count < msDigits {
|
||||
return temp * uint64(math.Pow(10, float64(msDigits-count)))
|
||||
}
|
||||
return temp / uint64(math.Pow(10, float64(count-msDigits)))
|
||||
}
|
||||
52
pkg/types/querybuildertypes/querybuildertypesv5/time_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestToMilliSecs(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
epoch uint64
|
||||
expected uint64
|
||||
}{
|
||||
{
|
||||
name: "SecondsEpoch_TenDigits_ScaledUp",
|
||||
epoch: 1672531200, // seconds, 2023-01-01 00:00:00 UTC
|
||||
expected: 1672531200000, // * 10^3
|
||||
},
|
||||
{
|
||||
name: "MilliSecondsEpoch_ThirteenDigits_Unchanged",
|
||||
epoch: 1672531200000,
|
||||
expected: 1672531200000, // unchanged
|
||||
},
|
||||
{
|
||||
name: "MicroSecondsEpoch_SixteenDigits_ScaledDown",
|
||||
epoch: 1672531200000000, // microseconds
|
||||
expected: 1672531200000, // / 10^3
|
||||
},
|
||||
{
|
||||
name: "NanoSecondsEpoch_NineteenDigits_ScaledDown",
|
||||
epoch: 1672531200000000000, // nanoseconds
|
||||
expected: 1672531200000, // / 10^6
|
||||
},
|
||||
{
|
||||
name: "Zero_Unchanged",
|
||||
epoch: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "SecondsEpoch_RecentTimestamp_ScaledUp",
|
||||
epoch: 1716638400, // 2024-05-25 12:00:00 UTC
|
||||
expected: 1716638400000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.expected, ToMilliSecs(testCase.epoch))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// maxEndTimeAhead tolerates client clock skew; anything further ahead is a malformed timestamp.
|
||||
const maxEndTimeAhead = 24 * time.Hour
|
||||
|
||||
// getQueryIdentifier returns a friendly identifier for a query based on its type and name/content.
|
||||
func getQueryIdentifier(envelope QueryEnvelope, index int) string {
|
||||
name := envelope.GetQueryName()
|
||||
@@ -574,12 +578,8 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
|
||||
|
||||
// Validate validates the entire query range request.
|
||||
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
// Validate time range
|
||||
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"start time must be before end time",
|
||||
)
|
||||
if err := r.validateTimeRange(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
@@ -631,8 +631,8 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
// this so per-query errors can be attributed individually via QueryEnvelope.Validate
|
||||
// instead of failing fast like Validate does.
|
||||
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
|
||||
if err := r.validateTimeRange(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
@@ -699,6 +699,22 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
}
|
||||
if r.Start == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time is required")
|
||||
}
|
||||
if r.Start >= r.End {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
|
||||
}
|
||||
if r.End > uint64(time.Now().Add(maxEndTimeAhead).UnixMilli()) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "end time %s is too far in the future", time.UnixMilli(int64(r.End)).UTC().Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
|
||||
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
|
||||
1
tests/fixtures/cloudintegrations.py
vendored
@@ -49,7 +49,6 @@ class ProviderAccountSpec:
|
||||
class ProviderServiceSpec:
|
||||
provider: str
|
||||
service_id: str
|
||||
|
||||
account_config: dict
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||