mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
8 Commits
feat/fga-r
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5a4c006e | ||
|
|
bb2c2382a7 | ||
|
|
9a570b1d31 | ||
|
|
555cb142db | ||
|
|
a6b6655e35 | ||
|
|
330b41f0ed | ||
|
|
e1b717fe36 | ||
|
|
d2565071e1 |
@@ -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
|
||||
@@ -13196,9 +13119,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- planned-maintenance:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- planned-maintenance:list
|
||||
- VIEWER
|
||||
summary: List downtime schedules
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -13253,13 +13176,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
- EDITOR
|
||||
summary: Create downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -13304,9 +13223,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- planned-maintenance:delete
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- planned-maintenance:delete
|
||||
- EDITOR
|
||||
summary: Delete downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -13362,9 +13281,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- planned-maintenance:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- planned-maintenance:read
|
||||
- VIEWER
|
||||
summary: Get downtime schedule by ID
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -13419,17 +13338,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- EDITOR
|
||||
summary: Update downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -14712,9 +14623,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- route-policy:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- route-policy:list
|
||||
- VIEWER
|
||||
summary: List route policies
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -14769,13 +14680,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- route-policy:create
|
||||
- route-policy:attach
|
||||
- notification-channel:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- route-policy:create
|
||||
- route-policy:attach
|
||||
- notification-channel:attach
|
||||
- ADMIN
|
||||
summary: Create route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -14820,9 +14727,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- route-policy:delete
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- route-policy:delete
|
||||
- ADMIN
|
||||
summary: Delete route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -14878,9 +14785,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- route-policy:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- route-policy:read
|
||||
- VIEWER
|
||||
summary: Get route policy by ID
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -14947,17 +14854,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- route-policy:update
|
||||
- route-policy:attach
|
||||
- route-policy:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- route-policy:update
|
||||
- route-policy:attach
|
||||
- route-policy:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- ADMIN
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
@@ -22355,9 +22254,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: List rule saved views
|
||||
tags:
|
||||
- rules
|
||||
@@ -22413,9 +22312,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: Create rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -22467,9 +22366,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: Delete rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -22536,9 +22435,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: Update rule saved view
|
||||
tags:
|
||||
- rules
|
||||
@@ -22587,9 +22486,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: List alert rules
|
||||
tags:
|
||||
- rules
|
||||
@@ -23428,23 +23327,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Create alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -23489,9 +23374,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:delete
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:delete
|
||||
- EDITOR
|
||||
summary: Delete alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -23547,9 +23432,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get alert rule by ID
|
||||
tags:
|
||||
- rules
|
||||
@@ -24400,27 +24285,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Patch alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -25259,27 +25126,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:update
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- notification-channel:attach
|
||||
- notification-channel:detach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Update alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -25346,25 +25195,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -25464,15 +25297,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- VIEWER
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -25580,15 +25407,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- VIEWER
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -25661,9 +25482,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -25733,9 +25554,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- VIEWER
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -25825,19 +25646,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -25910,19 +25721,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- VIEWER
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
@@ -26762,23 +26563,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- rule:create
|
||||
- rule:attach
|
||||
- notification-channel:attach
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- EDITOR
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
@@ -28644,9 +28431,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- rule:list
|
||||
- VIEWER
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const UNCONSTRAINED_ROUTE = '/unconstrained';
|
||||
|
||||
const renderOn = (
|
||||
compositeQuery: string,
|
||||
route: string = UNCONSTRAINED_ROUTE,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('compositeQuery', compositeQuery);
|
||||
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${route}?${search}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const logsWith = (item: object): string =>
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
|
||||
},
|
||||
});
|
||||
|
||||
const withHavingConverted = (queryData: object[]): object[] =>
|
||||
queryData.map((item) => ({ ...item, having: { expression: '' } }));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('treats a non-object or broken compositeQuery as no query', () => {
|
||||
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
|
||||
expect(renderOn(raw)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs the builder shape and converts the legacy having', () => {
|
||||
const defaultQueryData = withHavingConverted(
|
||||
initialQueryState.builder.queryData,
|
||||
);
|
||||
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
|
||||
(queryData) =>
|
||||
expect(
|
||||
renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData },
|
||||
}),
|
||||
)?.builder.queryData,
|
||||
).toStrictEqual(defaultQueryData),
|
||||
);
|
||||
|
||||
expect(
|
||||
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
|
||||
).toStrictEqual({ expression: '' });
|
||||
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gives an id-less query with a filter the same id on every parse', () => {
|
||||
const { id: _, ...withoutId } = JSON.parse(
|
||||
logsWith({ filter: { expression: "service.name='x'" } }),
|
||||
);
|
||||
const first = renderOn(JSON.stringify(withoutId));
|
||||
const again = renderOn(JSON.stringify(withoutId));
|
||||
const other = renderOn(
|
||||
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(first?.id).toHaveLength(36);
|
||||
expect(again?.id).toBe(first?.id);
|
||||
expect(other?.id).not.toBe(first?.id);
|
||||
});
|
||||
|
||||
it('applies the page constraints on every constrained route', () => {
|
||||
const clickhouseWithOperator = JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
});
|
||||
|
||||
[
|
||||
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
|
||||
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
|
||||
].forEach(([route, dataSource, source]) => {
|
||||
const query = renderOn(clickhouseWithOperator, route);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
|
||||
expect(query?.builder.queryData[0].source ?? '').toBe(source);
|
||||
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query type on a route without constraints', () => {
|
||||
const query = renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.metrics,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
|
||||
});
|
||||
});
|
||||
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type ExplorerQueryRules = {
|
||||
dataSource: DataSource;
|
||||
/** `meter` on the meter explorer, which shares the metrics signal; '' elsewhere. */
|
||||
source: BuilderQuerySource;
|
||||
builderQueryType: BuilderQueryType;
|
||||
allowsTraceOperator: boolean;
|
||||
};
|
||||
|
||||
const EXPLORER_QUERY_RULES: Partial<Record<string, ExplorerQueryRules>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
dataSource: DataSource.LOGS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function getExplorerQueryRules(
|
||||
pathname: string,
|
||||
): ExplorerQueryRules | undefined {
|
||||
return EXPLORER_QUERY_RULES[pathname];
|
||||
}
|
||||
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { normalizeCompositeQuery } from './normalizeCompositeQuery';
|
||||
|
||||
const NOT_AN_EXPLORER = ROUTES.HOME;
|
||||
|
||||
const logsQuery = initialQueryBuilderFormValuesMap.logs;
|
||||
const tracesQuery = initialQueryBuilderFormValuesMap.traces;
|
||||
const metricsQuery = initialQueryBuilderFormValuesMap.metrics;
|
||||
|
||||
function withBuilderQueries(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: queryData as IBuilderQuery[],
|
||||
...builder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstBuilderQuery(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
pathname: string,
|
||||
): IBuilderQuery {
|
||||
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
|
||||
.query.builder.queryData[0];
|
||||
}
|
||||
|
||||
function without(builderQuery: object, key: keyof IBuilderQuery): object {
|
||||
const { [key]: _, ...rest } = builderQuery as IBuilderQuery;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const traceOperator = { queryTraceOperator: [tracesQuery] };
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('leaves complete queries untouched', () => {
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
extra: 'kept',
|
||||
};
|
||||
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
|
||||
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
|
||||
]);
|
||||
|
||||
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, pathname),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false }),
|
||||
);
|
||||
expect(normalizeCompositeQuery(clickhouse, NOT_AN_EXPLORER)).toStrictEqual({
|
||||
query: clickhouse,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, [without(logsQuery, 'source')]),
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).changed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
normalizeCompositeQuery(aiQuery, ROUTES.AI_OBSERVABILITY_EXPLORER).changed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('adds missing blocks and a stable id without changing its inputs', () => {
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
const urlQueryBefore = JSON.stringify(builderless);
|
||||
const defaultsBefore = JSON.stringify(initialQueryState.builder);
|
||||
const repaired = normalizeCompositeQuery(builderless, NOT_AN_EXPLORER);
|
||||
repaired.query.builder.queryData[0].queryName = 'changed';
|
||||
const {
|
||||
promql: _p,
|
||||
clickhouse_sql: _c,
|
||||
...withoutText
|
||||
} = initialQueriesMap.logs;
|
||||
const withoutLists = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
},
|
||||
NOT_AN_EXPLORER,
|
||||
);
|
||||
const { id: _id, ...withoutId } = initialQueriesMap.logs;
|
||||
const { id } = normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query;
|
||||
|
||||
expect(repaired.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(repaired.changed).toBe(true);
|
||||
expect(JSON.stringify(builderless)).toBe(urlQueryBefore);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(defaultsBefore);
|
||||
expect(
|
||||
normalizeCompositeQuery(withoutText, NOT_AN_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
promql: initialQueryState.promql,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
});
|
||||
expect(withoutLists.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(withoutLists.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(withoutLists.changed).toBe(true);
|
||||
[[], [5, logsQuery]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, queryData),
|
||||
NOT_AN_EXPLORER,
|
||||
).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...withoutId, queryType: EQueryType.PROM },
|
||||
NOT_AN_EXPLORER,
|
||||
).query.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('switches explorer queries to the query builder and clears their text', () => {
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
);
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
|
||||
expect(withText.query).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
promql: initialQueryState.promql,
|
||||
});
|
||||
expect(withText.changed).toBe(true);
|
||||
expect(
|
||||
normalizeCompositeQuery(builderless, ROUTES.LOGS_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ ...logsQuery, source: '' }] },
|
||||
});
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets builder queries that do not match the explorer, keeping their names', () => {
|
||||
const logs = initialQueriesMap.logs;
|
||||
const traces = initialQueriesMap.traces;
|
||||
const ai = ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
const severityFilter = { filter: { expression: "severity='error'" } };
|
||||
const llmFilter = { filter: { expression: "name='llm'" } };
|
||||
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...tracesQuery, queryName: 'B', expression: 'B', ...llmFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({
|
||||
...logsQuery,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...without(logsQuery, 'dataSource'), ...severityFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toMatchObject({ dataSource: DataSource.LOGS, ...severityFilter });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).builderQueryType,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'D',
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({ ...tracesQuery, source: '', queryName: 'D' });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
initialQueriesMap.metrics,
|
||||
[{ ...without(metricsQuery, 'source'), filter: { expression: "x='y'" } }],
|
||||
ROUTES.METER_EXPLORER,
|
||||
),
|
||||
).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.metrics, ROUTES.METER_EXPLORER)
|
||||
.query.builder.queryData[0].source,
|
||||
).toBe(TelemetrytypesSourceDTO.meter);
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ai,
|
||||
),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a trace operator everywhere but the traces explorer', () => {
|
||||
const withOperator = (
|
||||
query: Query,
|
||||
pathname: string,
|
||||
): ReturnType<typeof normalizeCompositeQuery> =>
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(query, query.builder.queryData, traceOperator),
|
||||
pathname,
|
||||
);
|
||||
const onLogs = withOperator(initialQueriesMap.logs, ROUTES.LOGS_EXPLORER);
|
||||
const onTraces = withOperator(
|
||||
initialQueriesMap.traces,
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, isPlainObject } from 'lodash-es';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
import {
|
||||
ExplorerQueryRules,
|
||||
getExplorerQueryRules,
|
||||
} from './explorerQueryRules';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
/** The url query had to be repaired, so the url should be rewritten. */
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repairs a `compositeQuery` read from the url so the query builder can load it.
|
||||
* Links built by agents can leave out whole blocks, or carry a query the
|
||||
* current explorer cannot run.
|
||||
*/
|
||||
export function normalizeCompositeQuery(
|
||||
urlQuery: object,
|
||||
pathname: string,
|
||||
): NormalizedQuery {
|
||||
const query = cloneDeep(urlQuery) as Query;
|
||||
|
||||
addMissingBlocks(query, urlQuery);
|
||||
|
||||
const rules = getExplorerQueryRules(pathname);
|
||||
if (rules) {
|
||||
fitQueryToExplorer(query, rules);
|
||||
}
|
||||
|
||||
return { query, changed: !isEqual(query, urlQuery) };
|
||||
}
|
||||
|
||||
function addMissingBlocks(query: Query, urlQuery: object): void {
|
||||
if (!query.id) {
|
||||
query.id = getStableId(urlQuery);
|
||||
}
|
||||
|
||||
if (!Object.values(EQueryType).includes(query.queryType)) {
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.promql)) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.clickhouse_sql)) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!isPlainObject(query.builder)) {
|
||||
query.builder = cloneDeep(initialQueryState.builder);
|
||||
}
|
||||
|
||||
const { queryData } = query.builder;
|
||||
if (!isListOfObjects(queryData) || queryData.length === 0) {
|
||||
query.builder.queryData = cloneDeep(initialQueryState.builder.queryData);
|
||||
}
|
||||
|
||||
if (!query.builder.queryFormulas) {
|
||||
query.builder.queryFormulas = [];
|
||||
}
|
||||
|
||||
if (!query.builder.queryTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider matches the staged query to the url query by id, so a link
|
||||
* without an id must get the same one on every read.
|
||||
*/
|
||||
function getStableId(urlQuery: object): string {
|
||||
return uuidv5(JSON.stringify(urlQuery), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
}
|
||||
|
||||
function isListOfObjects(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every((item) => isPlainObject(item));
|
||||
}
|
||||
|
||||
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
|
||||
// Explorers only have the query builder: no PromQL or ClickHouse editor.
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
|
||||
if (query.promql.some((promql) => promql.query !== '')) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (query.clickhouse_sql.some((clickhouse) => clickhouse.query !== '')) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!rules.allowsTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
|
||||
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
|
||||
fitBuilderQueryToExplorer(builderQuery, rules),
|
||||
);
|
||||
}
|
||||
|
||||
function fitBuilderQueryToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): IBuilderQuery {
|
||||
fillMissingSignal(builderQuery, rules);
|
||||
|
||||
if (belongsToExplorer(builderQuery, rules)) {
|
||||
return builderQuery;
|
||||
}
|
||||
|
||||
const replacement = getDefaultBuilderQuery(rules);
|
||||
// Keep the name, so formulas that reference this query still resolve.
|
||||
replacement.queryName = builderQuery.queryName ?? replacement.queryName;
|
||||
replacement.expression = builderQuery.expression ?? replacement.expression;
|
||||
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/** A link may leave out the fields that pick the signal: read them as the explorer's. */
|
||||
function fillMissingSignal(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): void {
|
||||
if (builderQuery.dataSource === undefined) {
|
||||
builderQuery.dataSource = rules.dataSource;
|
||||
}
|
||||
|
||||
// A missing `source` already means '', so only meter needs it written.
|
||||
if (
|
||||
builderQuery.source === undefined &&
|
||||
rules.source === TelemetrytypesSourceDTO.meter
|
||||
) {
|
||||
builderQuery.source = rules.source;
|
||||
}
|
||||
|
||||
// A missing `builderQueryType` already means builder_query, so only AI needs it written.
|
||||
if (
|
||||
builderQuery.builderQueryType === undefined &&
|
||||
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
|
||||
) {
|
||||
builderQuery.builderQueryType = rules.builderQueryType;
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): boolean {
|
||||
if (builderQuery.dataSource !== rules.dataSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((builderQuery.source ?? '') !== rules.source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const builderQueryType =
|
||||
builderQuery.builderQueryType ??
|
||||
Querybuildertypesv5QueryTypeDTO.builder_query;
|
||||
|
||||
if (builderQueryType !== rules.builderQueryType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
|
||||
const builderQuery = cloneDeep(
|
||||
initialQueryBuilderFormValuesMap[rules.dataSource],
|
||||
);
|
||||
builderQuery.source = rules.source;
|
||||
|
||||
if (
|
||||
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
|
||||
) {
|
||||
builderQuery.builderQueryType = rules.builderQueryType;
|
||||
}
|
||||
|
||||
return builderQuery;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,23 @@ import {
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
import {
|
||||
NormalizedQuery,
|
||||
normalizeCompositeQuery,
|
||||
} from './normalizeCompositeQuery';
|
||||
|
||||
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
let changed = false;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
@@ -23,18 +32,18 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
const rawQuery: unknown = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
if (!isPlainObject(rawQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
rawQuery as object,
|
||||
pathname,
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
@@ -51,7 +60,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
const convertedHaving = convertHavingToExpression(
|
||||
query.having.filter(isPlainObject),
|
||||
);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
@@ -75,6 +86,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
|
||||
}, [urlQuery, pathname]);
|
||||
};
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null =>
|
||||
useNormalizedCompositeQueryParam()?.query ?? null;
|
||||
|
||||
@@ -21,15 +21,7 @@ export default {
|
||||
{
|
||||
kind: 'notification-channel',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: [
|
||||
'attach',
|
||||
'create',
|
||||
'delete',
|
||||
'detach',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'quick-filter',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function replaceIncorrectObjectFields<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
TargetValue extends object,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ResultValue extends object,
|
||||
>(
|
||||
targetObject: TargetValue,
|
||||
defaultObject: ResultValue,
|
||||
): { isValid: boolean; validData: ResultValue } {
|
||||
const targetObjectKeys = Object.keys(targetObject);
|
||||
const defaultObjectKeys = Object.keys(defaultObject);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
const result: ResultValue = { ...defaultObject };
|
||||
|
||||
defaultObjectKeys.forEach((key) => {
|
||||
if (targetObjectKeys.includes(key)) {
|
||||
result[key as keyof ResultValue] = targetObject[
|
||||
key as keyof TargetValue
|
||||
] as unknown as ResultValue[keyof ResultValue];
|
||||
} else {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, validData: result };
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,14 +34,13 @@ import {
|
||||
PartialPanelTypes,
|
||||
} from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
// ** Types
|
||||
@@ -114,7 +113,8 @@ export function QueryBuilderProvider({
|
||||
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const compositeQueryParam = useGetCompositeQueryParam();
|
||||
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
|
||||
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
|
||||
const { queryType: queryTypeParam, ...queryState } =
|
||||
compositeQueryParam || initialQueriesMap.metrics;
|
||||
|
||||
@@ -1115,20 +1115,22 @@ export function QueryBuilderProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const { isValid, validData } = replaceIncorrectObjectFields(
|
||||
compositeQueryParam,
|
||||
initialQueriesMap.metrics,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
redirectWithQueryBuilderData(validData);
|
||||
} else {
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
if (normalizedCompositeQuery?.changed) {
|
||||
const urlQuery = getUnstableCurrentSearchParams();
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQueryParam)),
|
||||
);
|
||||
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
}
|
||||
}, [
|
||||
initQueryBuilderData,
|
||||
redirectWithQueryBuilderData,
|
||||
safeNavigate,
|
||||
normalizedCompositeQuery,
|
||||
compositeQueryParam,
|
||||
stagedQuery,
|
||||
location.pathname,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
.createBrowserHistorySafeNavigateMock(),
|
||||
);
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const clickhouseUrlQuery = {
|
||||
...builderlessUrlQuery,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
|
||||
const tracesWithOperator = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
};
|
||||
|
||||
const urlQuery = (): Query =>
|
||||
JSON.parse(
|
||||
decodeURIComponent(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
const activeLogIdParam = (): string | null =>
|
||||
new URLSearchParams(window.location.search).get('activeLogId');
|
||||
|
||||
const renderOn = (
|
||||
route: string,
|
||||
compositeQuery: object,
|
||||
params: Record<string, string> = {},
|
||||
): RenderHookResult<QueryBuilderContextType, unknown> => {
|
||||
const search = new URLSearchParams(params);
|
||||
search.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
const url = `${route}?${search}`;
|
||||
window.history.replaceState(null, '', url);
|
||||
|
||||
return renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
|
||||
|
||||
expect(result.current.supersetQuery.builder).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetQueryData(0, {
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
|
||||
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryBuilderProvider with page constraints', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
|
||||
const replaceState = jest.spyOn(window.history, 'replaceState');
|
||||
const pushState = jest.spyOn(window.history, 'pushState');
|
||||
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
|
||||
|
||||
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('');
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
expect(replaceState).toHaveBeenCalled();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a trace operator on the traces explorer', async () => {
|
||||
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
|
||||
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
|
||||
};
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
|
||||
);
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery'),
|
||||
).toBe(JSON.stringify(query));
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
});
|
||||
|
||||
it('leaves a clickhouse query alone on a route without constraints', async () => {
|
||||
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
|
||||
);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,6 @@ type Alertmanager interface {
|
||||
// ListChannels lists all channels for the organization.
|
||||
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
|
||||
|
||||
// GetChannelIDsByNames resolves display names to channel ids and rejects unknown names.
|
||||
GetChannelIDsByNames(context.Context, string, []string) ([]string, error)
|
||||
|
||||
// ListAllChannels lists all channels for all organizations. It is used by the legacy alertmanager only.
|
||||
ListAllChannels(context.Context) ([]*alertmanagertypes.Channel, error)
|
||||
|
||||
|
||||
@@ -1023,80 +1023,6 @@ func (_c *MockAlertmanager_GetChannelByID_Call) RunAndReturn(run func(context1 c
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetChannelIDsByNames provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) GetChannelIDsByNames(context1 context.Context, s string, strings []string) ([]string, error) {
|
||||
ret := _mock.Called(context1, s, strings)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetChannelIDsByNames")
|
||||
}
|
||||
|
||||
var r0 []string
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) ([]string, error)); ok {
|
||||
return returnFunc(context1, s, strings)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) []string); ok {
|
||||
r0 = returnFunc(context1, s, strings)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok {
|
||||
r1 = returnFunc(context1, s, strings)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockAlertmanager_GetChannelIDsByNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChannelIDsByNames'
|
||||
type MockAlertmanager_GetChannelIDsByNames_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetChannelIDsByNames is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - strings []string
|
||||
func (_e *MockAlertmanager_Expecter) GetChannelIDsByNames(context1 any, s any, strings any) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
return &MockAlertmanager_GetChannelIDsByNames_Call{Call: _e.mock.On("GetChannelIDsByNames", context1, s, strings)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Run(run func(context1 context.Context, s string, strings []string)) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 []string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].([]string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) Return(strings1 []string, err error) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Return(strings1, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_GetChannelIDsByNames_Call) RunAndReturn(run func(context1 context.Context, s string, strings []string) ([]string, error)) *MockAlertmanager_GetChannelIDsByNames_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetConfig provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) GetConfig(context1 context.Context, s string) (*alertmanagertypes.Config, error) {
|
||||
ret := _mock.Called(context1, s)
|
||||
|
||||
@@ -147,19 +147,6 @@ func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*al
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func (provider *provider) GetChannelIDsByNames(ctx context.Context, orgID string, names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
channels, _, err := provider.configStore.ListChannels(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagertypes.MapChannelNamesToIDs(channels, names)
|
||||
}
|
||||
|
||||
func (provider *provider) ListNotificationChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
|
||||
channels, total, err := provider.configStore.ListChannels(ctx, orgID, params)
|
||||
if err != nil {
|
||||
@@ -541,10 +528,6 @@ func (provider *provider) CreateRoutePolicy(ctx context.Context, routeRequest *a
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), routeRequest.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
route := alertmanagertypes.RoutePolicy{
|
||||
Expression: routeRequest.Expression,
|
||||
ExpressionKind: routeRequest.ExpressionKind,
|
||||
@@ -737,10 +720,6 @@ func (provider *provider) UpdateRoutePolicyByID(ctx context.Context, routeID str
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid route: %v", err)
|
||||
}
|
||||
|
||||
if _, err := provider.GetChannelIDsByNames(ctx, orgID.String(), route.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingRoute, err := provider.notificationManager.GetRoutePolicyByID(ctx, claims.OrgID, routeID)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeNotFound, "route not found: %v", err)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -328,167 +327,88 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetAllRoutePolicies, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "List route policies",
|
||||
Description: "This endpoint lists all route policies for the organization",
|
||||
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "List route policies",
|
||||
Description: "This endpoint lists all route policies for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetRoutePolicyByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Get route policy by ID",
|
||||
Description: "This endpoint returns a route policy by ID",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetRoutePolicyByID), handler.OpenAPIDef{
|
||||
ID: "GetRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Get route policy by ID",
|
||||
Description: "This endpoint returns a route policy by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.CreateRoutePolicy, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Create route policy",
|
||||
Description: "This endpoint creates a route policy",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.CreateRoutePolicy), handler.OpenAPIDef{
|
||||
ID: "CreateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Create route policy",
|
||||
Description: "This endpoint creates a route policy",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.UpdateRoutePolicy, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Update route policy",
|
||||
Description: "This endpoint updates a route policy by ID",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewUpdateRoutePolicyChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewUpdateRoutePolicyChannelDetachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.UpdateRoutePolicy), handler.OpenAPIDef{
|
||||
ID: "UpdateRoutePolicy",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Update route policy",
|
||||
Description: "This endpoint updates a route policy by ID",
|
||||
Request: new(alertmanagertypes.PostableRoutePolicy),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableRoutePolicy),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.DeleteRoutePolicyByID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Delete route policy",
|
||||
Description: "This endpoint deletes a route policy by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRoutePolicy.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRoutePolicy,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/route_policies/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.alertmanagerHandler.DeleteRoutePolicyByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRoutePolicyByID",
|
||||
Tags: []string{"routepolicies"},
|
||||
Summary: "Delete route policy",
|
||||
Description: "This endpoint deletes a route policy by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -91,11 +91,9 @@ type provider struct {
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
alertmanagerService alertmanager.Alertmanager
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
rulerService ruler.Ruler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
@@ -138,12 +136,10 @@ func NewFactory(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -193,12 +189,10 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
alertmanagerService,
|
||||
prometheusHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
rulerService,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
globalConfig,
|
||||
@@ -250,12 +244,10 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -307,11 +299,9 @@ func newProvider(
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
alertmanagerService: alertmanagerService,
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
rulerService: rulerService,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
|
||||
@@ -4,633 +4,273 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/rules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRulesV3, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Get alert rule by ID",
|
||||
Description: "This endpoint returns an alert rule by ID",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
|
||||
ID: "GetRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Get alert rule by ID",
|
||||
Description: "This endpoint returns an alert rule by ID",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create alert rule",
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
|
||||
ID: "CreateRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create alert rule",
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CloneRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCloneRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewReplaceRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewReplaceRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.PatchRuleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "PatchRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Patch alert rule",
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDetach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewPatchRuleChannelAttachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewPatchRuleChannelDetachExtractor(provider.alertmanagerService, provider.rulerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewOptionalRuleBodyTelemetryResources(),
|
||||
Optional: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/test", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.TestRule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "TestRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule",
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.GettableTestRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbAttach),
|
||||
}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourceRule,
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
TargetIDs: ruler.NewCreateRuleChannelAttachExtractor(provider.alertmanagerService),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleBodyTelemetryResources(),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
|
||||
ID: "PatchRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Patch alert rule",
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRuleViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListRuleViews",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List rule saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Response: new(ruletypes.ListableRuleViews),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rules/test", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
|
||||
ID: "TestRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule",
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: postableRuleExamples(),
|
||||
Response: new(ruletypes.GettableTestRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create rule saved view",
|
||||
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(ruletypes.PostableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
|
||||
ID: "ListRuleViews",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List rule saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Response: new(ruletypes.ListableRuleViews),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update rule saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(ruletypes.UpdatableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
|
||||
ID: "CreateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create rule saved view",
|
||||
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(ruletypes.PostableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete rule saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update rule saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(ruletypes.UpdatableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete rule saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.DowntimeRuleIDsExtractor,
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -16,7 +13,7 @@ import (
|
||||
func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/stats", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryStats),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryStats",
|
||||
Tags: []string{"rules"},
|
||||
@@ -27,21 +24,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryTimeline),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTimeline",
|
||||
Tags: []string{"rules"},
|
||||
@@ -52,26 +41,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryContributors, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryContributors),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryTopContributors",
|
||||
Tags: []string{"rules"},
|
||||
@@ -82,26 +58,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, telemetryReadScopes()...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: ruler.NewRuleQueryTelemetryResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_keys", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterKeys),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterKeys",
|
||||
Tags: []string{"rules"},
|
||||
@@ -112,26 +75,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbList)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/filter_values", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryFilterValues),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryFilterValues",
|
||||
Tags: []string{"rules"},
|
||||
@@ -142,26 +92,13 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes(append([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}, buildFieldScopes(coretypes.VerbRead)...)),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}, handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: ruler.NewRuleQueryFieldResources(provider.rulerService),
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
provider.authzMiddleware.ViewAccess(provider.ruleStateHistoryHandler.GetRuleHistoryOverallStatus),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRuleHistoryOverallStatus",
|
||||
Tags: []string{"rules"},
|
||||
@@ -172,26 +109,10 @@ func (provider *provider) addRuleStateHistoryRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceRule,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFieldScopes(verb coretypes.Verb) []string {
|
||||
return []string{
|
||||
coretypes.ResourceMetaResourceLogsField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceTracesField.Scope(verb),
|
||||
coretypes.ResourceMetaResourceMetricsField.Scope(verb),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,6 @@ type TelemetryResourceDef struct {
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
// Optional skips the check instead of failing when the request carries no telemetry resources.
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -118,9 +116,6 @@ func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []
|
||||
if err != nil {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
|
||||
}
|
||||
if len(refs) == 0 && def.Optional {
|
||||
return nil
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
|
||||
def.Verb,
|
||||
|
||||
@@ -69,43 +69,3 @@ func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
}
|
||||
|
||||
func TestTelemetryResourceDefWithoutResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
optional bool
|
||||
wantLen int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Required_Errors",
|
||||
optional: false,
|
||||
wantLen: 1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Optional_ResolvesNothing",
|
||||
optional: true,
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
def := TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) { return nil, nil },
|
||||
Optional: testCase.optional,
|
||||
}
|
||||
|
||||
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{})
|
||||
|
||||
require.Len(t, resolved, testCase.wantLen)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, resolved[0].Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,24 +1043,29 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRule, err := ruletypes.NewPatchedPostableRule([]byte(storedJSON.Data), []byte(ruleStr))
|
||||
if err != nil {
|
||||
storedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(storedJSON.Data), &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := storedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateChannels(ctx, claims.OrgID, storedRule); err != nil {
|
||||
if err := m.validateChannels(ctx, claims.OrgID, &storedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// deploy or un-deploy task according to patched (new) rule state
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to sync stored rule state with the task", slog.String("task.name", taskName), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newStoredJson, err := json.Marshal(storedRule)
|
||||
newStoredJson, err := json.Marshal(&storedRule)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to marshal new stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
@@ -1073,7 +1078,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
|
||||
err = m.ruleStore.EditRule(ctx, storedJSON, func(ctx context.Context) error { return nil })
|
||||
if err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, storedRule); err != nil {
|
||||
if err := m.syncRuleStateWithTask(ctx, orgID, taskName, &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to restore rule after patch failure", "task_name", taskName, errors.Attr(err))
|
||||
}
|
||||
return nil, err
|
||||
@@ -1082,7 +1087,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
// prepare http response
|
||||
response := ruletypes.GettableRule{
|
||||
Id: id.StringValue(),
|
||||
PostableRule: *storedRule,
|
||||
PostableRule: storedRule,
|
||||
}
|
||||
|
||||
// fetch state of rule from memory
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
attachedSide diffSide = iota
|
||||
detachedSide
|
||||
)
|
||||
|
||||
type diffSide int
|
||||
|
||||
type incomingChannelsFunc func(stored *ruletypes.GettableRule, body []byte) ([]string, error)
|
||||
|
||||
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return defaultToAllRules(ids), nil
|
||||
}}
|
||||
|
||||
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
if id == coretypes.WildCardSelectorString {
|
||||
return coretypes.WildcardSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
return coretypes.IDSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, attachedSide)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, detachedSide)
|
||||
}
|
||||
|
||||
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ec.Request.Context(), scheduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
|
||||
if side == detachedSide {
|
||||
return detached, nil
|
||||
}
|
||||
|
||||
return attached, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func NewCreateRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names, err := parseRuleChannels(ec.RequestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), names)
|
||||
}}
|
||||
}
|
||||
|
||||
func NewCloneRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), stored.Channels())
|
||||
}}
|
||||
}
|
||||
|
||||
func NewReplaceRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, attachedSide)
|
||||
}
|
||||
|
||||
func NewReplaceRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parseReplacedRuleChannels, detachedSide)
|
||||
}
|
||||
|
||||
func NewPatchRuleChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, attachedSide)
|
||||
}
|
||||
|
||||
func NewPatchRuleChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newRuleChannelDiffExtractor(alertmanagerService, rulerService, parsePatchedRuleChannels, detachedSide)
|
||||
}
|
||||
|
||||
func newRuleChannelDiffExtractor(alertmanagerService alertmanager.Alertmanager, rulerService Ruler, incoming incomingChannelsFunc, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incomingNames, err := incoming(stored, ec.RequestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffLinks(stored.Channels(), incomingNames)
|
||||
if side == detachedSide {
|
||||
return resolveStoredChannelIDs(ctx, alertmanagerService, orgID, detached)
|
||||
}
|
||||
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), attached)
|
||||
}}
|
||||
}
|
||||
|
||||
func parseReplacedRuleChannels(_ *ruletypes.GettableRule, body []byte) ([]string, error) {
|
||||
return parseRuleChannels(body)
|
||||
}
|
||||
|
||||
func parsePatchedRuleChannels(stored *ruletypes.GettableRule, body []byte) ([]string, error) {
|
||||
patched, err := applyRulePatch(stored, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return patched.Channels(), nil
|
||||
}
|
||||
|
||||
func applyRulePatch(stored *ruletypes.GettableRule, body []byte) (*ruletypes.PostableRule, error) {
|
||||
encoded, err := json.Marshal(stored.PostableRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ruletypes.NewPatchedPostableRule(encoded, body)
|
||||
}
|
||||
|
||||
func parseRuleChannels(body []byte) ([]string, error) {
|
||||
rule := new(ruletypes.PostableRule)
|
||||
if err := json.Unmarshal(body, rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rule.Channels(), nil
|
||||
}
|
||||
|
||||
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
|
||||
return diffLinks(defaultToAllRules(stored), incoming)
|
||||
}
|
||||
|
||||
func defaultToAllRules(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names, err := coretypes.BodyJSONArray("channels").Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), names)
|
||||
}}
|
||||
}
|
||||
|
||||
func NewUpdateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return newRoutePolicyDiffExtractor(alertmanagerService, attachedSide)
|
||||
}
|
||||
|
||||
func NewUpdateRoutePolicyChannelDetachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return newRoutePolicyDiffExtractor(alertmanagerService, detachedSide)
|
||||
}
|
||||
|
||||
func newRoutePolicyDiffExtractor(alertmanagerService alertmanager.Alertmanager, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := alertmanagerService.GetRoutePolicyByID(ctx, mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incoming, err := coretypes.BodyJSONArray("channels").Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffLinks(stored.Channels, incoming)
|
||||
if side == detachedSide {
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), detached)
|
||||
}
|
||||
|
||||
return alertmanagerService.GetChannelIDsByNames(ctx, orgID.StringValue(), attached)
|
||||
}}
|
||||
}
|
||||
|
||||
func resolveStoredChannelIDs(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID, names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
idByName, err := mapChannelIDsByName(ctx, alertmanagerService, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if id, ok := idByName[name]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func mapChannelIDsByName(ctx context.Context, alertmanagerService alertmanager.Alertmanager, orgID valuer.UUID) (map[string]string, error) {
|
||||
channels, err := alertmanagerService.ListChannels(ctx, orgID.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idByName := make(map[string]string, len(channels))
|
||||
for _, channel := range channels {
|
||||
idByName[channel.DisplayName] = channel.ID.StringValue()
|
||||
}
|
||||
|
||||
return idByName, nil
|
||||
}
|
||||
|
||||
func orgIDFromContext(ctx context.Context) (valuer.UUID, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return valuer.NewUUID(claims.OrgID)
|
||||
}
|
||||
|
||||
func NewRuleQueryTelemetryResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
|
||||
}
|
||||
}
|
||||
|
||||
func NewRuleQueryFieldResources(rulerService Ruler) coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
body, err := fetchStoredCompositeQuery(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapFieldResources(body)
|
||||
}
|
||||
}
|
||||
|
||||
func NewRuleBodyTelemetryResources() coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
compositeQuery := gjson.GetBytes(ec.RequestBody, "condition.compositeQuery")
|
||||
if !compositeQuery.Exists() {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery is required")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]json.RawMessage{"compositeQuery": json.RawMessage(compositeQuery.Raw)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return querybuilder.QueryRangeResources(coretypes.ExtractorContext{Request: ec.Request, RequestBody: body})
|
||||
}
|
||||
}
|
||||
|
||||
func NewOptionalRuleBodyTelemetryResources() coretypes.ResourceExtractor {
|
||||
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
if !gjson.GetBytes(ec.RequestBody, "condition.compositeQuery").Exists() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return NewRuleBodyTelemetryResources()(ec)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStoredRule(ec coretypes.ExtractorContext, rulerService Ruler) (*ruletypes.GettableRule, error) {
|
||||
ruleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rulerService.GetRule(ec.Request.Context(), ruleID)
|
||||
}
|
||||
|
||||
func fetchStoredCompositeQuery(ec coretypes.ExtractorContext, rulerService Ruler) ([]byte, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stored, err := fetchStoredRule(ec, rulerService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{"compositeQuery": stored.RuleCondition.CompositeQuery})
|
||||
}
|
||||
|
||||
func mapFieldResources(body []byte) ([]coretypes.ResourceWithID, error) {
|
||||
telemetry, err := querybuilder.QueryRangeResources(coretypes.ExtractorContext{RequestBody: body})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, 3)
|
||||
refs := make([]coretypes.ResourceWithID, 0, 3)
|
||||
for _, ref := range telemetry {
|
||||
field, err := fieldResourceForKind(ref.Resource.Kind())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind := field.Kind().String()
|
||||
if _, ok := seen[kind]; ok {
|
||||
continue
|
||||
}
|
||||
seen[kind] = struct{}{}
|
||||
refs = append(refs, coretypes.ResourceWithID{Resource: field})
|
||||
}
|
||||
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func fieldResourceForKind(kind coretypes.Kind) (coretypes.Resource, error) {
|
||||
switch kind {
|
||||
case coretypes.KindLogs, coretypes.KindAuditLogs:
|
||||
return coretypes.ResourceMetaResourceLogsField, nil
|
||||
case coretypes.KindTraces:
|
||||
return coretypes.ResourceMetaResourceTracesField, nil
|
||||
case coretypes.KindMetrics, coretypes.KindMeterMetrics:
|
||||
return coretypes.ResourceMetaResourceMetricsField, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "no field resource for telemetry kind %q", kind.String())
|
||||
}
|
||||
}
|
||||
|
||||
func diffLinks(stored, incoming []string) (attached, detached []string) {
|
||||
storedSet := make(map[string]struct{}, len(stored))
|
||||
for _, id := range stored {
|
||||
storedSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
incomingSet := make(map[string]struct{}, len(incoming))
|
||||
for _, id := range incoming {
|
||||
incomingSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(incoming))
|
||||
attached = make([]string, 0, len(incoming))
|
||||
for _, id := range incoming {
|
||||
if _, ok := storedSet[id]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
attached = append(attached, id)
|
||||
}
|
||||
|
||||
clear(seen)
|
||||
detached = make([]string, 0, len(stored))
|
||||
for _, id := range stored {
|
||||
if _, ok := incomingSet[id]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
detached = append(detached, id)
|
||||
}
|
||||
|
||||
return attached, detached
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDiffLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
stored []string
|
||||
incoming []string
|
||||
wantAttached []string
|
||||
wantDetached []string
|
||||
}{
|
||||
{
|
||||
name: "BothEmpty_NothingChanges",
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "NoStored_EverythingAttaches",
|
||||
incoming: []string{"slack", "pager"},
|
||||
wantAttached: []string{"slack", "pager"},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "NoIncoming_EverythingDetaches",
|
||||
stored: []string{"slack", "pager"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{"slack", "pager"},
|
||||
},
|
||||
{
|
||||
name: "Unchanged_NothingChanges",
|
||||
stored: []string{"slack", "pager"},
|
||||
incoming: []string{"pager", "slack"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "OneAddedOneRemoved_BothSidesReported",
|
||||
stored: []string{"slack", "pager"},
|
||||
incoming: []string{"slack", "email"},
|
||||
wantAttached: []string{"email"},
|
||||
wantDetached: []string{"pager"},
|
||||
},
|
||||
{
|
||||
name: "DuplicateIncoming_CollapsedOnce",
|
||||
incoming: []string{"slack", "slack"},
|
||||
wantAttached: []string{"slack"},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "DuplicateStored_CollapsedOnce",
|
||||
stored: []string{"pager", "pager"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{"pager"},
|
||||
},
|
||||
{
|
||||
name: "IncomingOrderPreserved",
|
||||
stored: []string{"keep"},
|
||||
incoming: []string{"zebra", "keep", "apple"},
|
||||
wantAttached: []string{"zebra", "apple"},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
attached, detached := diffLinks(testCase.stored, testCase.incoming)
|
||||
|
||||
assert.Equal(t, testCase.wantAttached, attached)
|
||||
assert.Equal(t, testCase.wantDetached, detached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDowntimeRuleLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
stored []string
|
||||
incoming []string
|
||||
wantAttached []string
|
||||
wantDetached []string
|
||||
}{
|
||||
{
|
||||
name: "AllToAll_NoChange",
|
||||
stored: nil,
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
|
||||
stored: nil,
|
||||
incoming: []string{"r1"},
|
||||
wantAttached: []string{"r1"},
|
||||
wantDetached: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "WidenFromOneToAll_DetachesOneAttachesAll",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{"*"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
{
|
||||
name: "SwapRules_DiffsIDs",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"r2"},
|
||||
wantAttached: []string{"r2"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
|
||||
|
||||
assert.ElementsMatch(t, testCase.wantAttached, attached)
|
||||
assert.ElementsMatch(t, testCase.wantDetached, detached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowntimeRuleIDsExtractor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "MissingAlertIds_AllRules",
|
||||
body: `{"name": "window"}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "EmptyAlertIds_AllRules",
|
||||
body: `{"alertIds": []}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "ExplicitAlertIds_Kept",
|
||||
body: `{"alertIds": ["r1", "r2"]}`,
|
||||
wantIDs: []string{"r1", "r2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapFieldResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKinds []coretypes.Kind
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Builder_Logs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Traces_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "Builder_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "SubQuery_Metrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_sub_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "BuilderAI_TracesField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindTracesField},
|
||||
},
|
||||
{
|
||||
name: "PromQL_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_AllThreeFields",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField, coretypes.KindTracesField, coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_AuditLogs_LogsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "Builder_MeterMetrics_MetricsField",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","source":"meter"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "TwoBuilderSameSignal_Deduped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}},{"type":"builder_query","spec":{"signal":"metrics"}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindMetricsField},
|
||||
},
|
||||
{
|
||||
name: "FormulaAlongsideBuilder_FormulaSkipped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}},{"type":"builder_formula","spec":{}}]}}`,
|
||||
wantKinds: []coretypes.Kind{coretypes.KindLogsField},
|
||||
},
|
||||
{
|
||||
name: "NoQueries_Errors",
|
||||
body: `{"compositeQuery":{"queries":[]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownSignal_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"events"}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownQueryType_Errors",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"graphql","spec":{}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := mapFieldResources([]byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
kinds := make([]coretypes.Kind, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
kinds = append(kinds, ref.Resource.Kind())
|
||||
}
|
||||
assert.Equal(t, testCase.wantKinds, kinds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func v1RuleJSON(preferredChannels string) string {
|
||||
return `{
|
||||
"alert": "TestAlert",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}` + preferredChannels + `
|
||||
}`
|
||||
}
|
||||
|
||||
func v2RuleJSON(channels string) string {
|
||||
return `{
|
||||
"alert": "TestAlert",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{"name": "critical", "target": 90.0, "matchType": "1", "op": "1", "channels": ` + channels + `}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {"evalWindow": "5m", "frequency": "1m"}
|
||||
},
|
||||
"notificationSettings": {
|
||||
"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func TestParseRuleChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantChannels []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "V1PreferredChannels_FoldedIntoThresholds",
|
||||
body: v1RuleJSON(`, "preferredChannels": ["slack", "pager"]`),
|
||||
wantChannels: []string{"slack", "pager"},
|
||||
},
|
||||
{
|
||||
name: "V1NoChannels_Empty",
|
||||
body: v1RuleJSON(""),
|
||||
wantChannels: nil,
|
||||
},
|
||||
{
|
||||
name: "V2ThresholdChannels_ReadDirectly",
|
||||
body: v2RuleJSON(`["email"]`),
|
||||
wantChannels: []string{"email"},
|
||||
},
|
||||
{
|
||||
name: "V2EmptyChannels_Empty",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantChannels: nil,
|
||||
},
|
||||
{
|
||||
name: "NotJSON_Errors",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channels, err := parseRuleChannels([]byte(testCase.body))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantChannels, channels)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingRuleChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
incoming incomingChannelsFunc
|
||||
stored string
|
||||
body string
|
||||
wantChannels []string
|
||||
wantStoredAfter []string
|
||||
}{
|
||||
{
|
||||
name: "Replaced_IgnoresStored",
|
||||
incoming: parseReplacedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`["pager"]`),
|
||||
wantChannels: []string{"pager"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_OmitsCondition_StoredChannelsSurvive",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: `{"disabled": true}`,
|
||||
wantChannels: []string{"slack"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_SetsChannels_StoredChannelsReplaced",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`["pager"]`),
|
||||
wantChannels: []string{"pager"},
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "Patched_EmptiesChannels_NoChannelsLeft",
|
||||
incoming: parsePatchedRuleChannels,
|
||||
stored: v2RuleJSON(`["slack"]`),
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantChannels: nil,
|
||||
wantStoredAfter: []string{"slack"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stored := new(ruletypes.GettableRule)
|
||||
require.NoError(t, json.Unmarshal([]byte(testCase.stored), &stored.PostableRule))
|
||||
|
||||
channels, err := testCase.incoming(stored, []byte(testCase.body))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantChannels, channels)
|
||||
assert.Equal(t, testCase.wantStoredAfter, stored.Channels())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleBodyTelemetryResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantKind coretypes.Kind
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "V5Body_NoFilter_MetricsWildcard",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantKind: coretypes.KindMetrics,
|
||||
wantID: "builder_query/*",
|
||||
},
|
||||
{
|
||||
name: "V1Body_PreferredChannels_MetricsWildcard",
|
||||
body: v1RuleJSON(`, "preferredChannels": ["slack"]`),
|
||||
wantKind: coretypes.KindMetrics,
|
||||
wantID: "builder_query/*",
|
||||
},
|
||||
{
|
||||
name: "MissingCondition_Errors",
|
||||
body: `{"alert": "TestAlert", "version": "v5"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "NotJSON_Errors",
|
||||
body: "not json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := NewRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, refs, 1)
|
||||
assert.Equal(t, testCase.wantKind, refs[0].Resource.Kind())
|
||||
assert.Equal(t, testCase.wantID, refs[0].ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRulePatch(t *testing.T) {
|
||||
logsQuery := `{"condition": {"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "stepInterval": "5m"}}]}}}`
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSignal string
|
||||
wantChannels []string
|
||||
}{
|
||||
{
|
||||
name: "BodyWithoutCondition_KeepsStoredQueryAndChannels",
|
||||
body: `{"disabled": true}`,
|
||||
wantSignal: "metrics",
|
||||
wantChannels: []string{"slack"},
|
||||
},
|
||||
{
|
||||
name: "BodyWithNewQuery_ReplacesSignalKeepsThresholds",
|
||||
body: logsQuery,
|
||||
wantSignal: "logs",
|
||||
wantChannels: []string{"slack"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stored := new(ruletypes.GettableRule)
|
||||
require.NoError(t, json.Unmarshal([]byte(v2RuleJSON(`["slack"]`)), &stored.PostableRule))
|
||||
|
||||
patched, err := applyRulePatch(stored, []byte(testCase.body))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, patched.RuleCondition)
|
||||
require.NotNil(t, patched.RuleCondition.CompositeQuery)
|
||||
encoded, err := json.Marshal(patched.RuleCondition.CompositeQuery)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), `"signal":"`+testCase.wantSignal+`"`)
|
||||
assert.Equal(t, testCase.wantChannels, patched.Channels())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalRuleBodyTelemetryResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantRefs int
|
||||
}{
|
||||
{
|
||||
name: "BodyWithoutCondition_NoResources",
|
||||
body: `{"disabled": true}`,
|
||||
wantRefs: 0,
|
||||
},
|
||||
{
|
||||
name: "BodyWithQuery_MetricsResource",
|
||||
body: v2RuleJSON(`[]`),
|
||||
wantRefs: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := NewOptionalRuleBodyTelemetryResources()(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, refs, testCase.wantRefs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStoredChannelIDs(t *testing.T) {
|
||||
channels := []*alertmanagertypes.Channel{
|
||||
{Identifiable: types.Identifiable{ID: valuer.GenerateUUID()}, Name: "slack", DisplayName: "slack"},
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
names []string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "KnownName_Resolved",
|
||||
names: []string{"slack"},
|
||||
wantIDs: []string{channels[0].ID.StringValue()},
|
||||
},
|
||||
{
|
||||
name: "UnknownName_Skipped",
|
||||
names: []string{"slack", "gone"},
|
||||
wantIDs: []string{channels[0].ID.StringValue()},
|
||||
},
|
||||
{
|
||||
name: "NoNames_NoLookup",
|
||||
names: nil,
|
||||
wantIDs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
alertmanagerService := alertmanagertest.NewMockAlertmanager(t)
|
||||
if len(testCase.names) > 0 {
|
||||
alertmanagerService.EXPECT().ListChannels(mock.Anything, mock.Anything).Return(channels, nil)
|
||||
}
|
||||
|
||||
ids, err := resolveStoredChannelIDs(context.Background(), alertmanagerService, valuer.GenerateUUID(), testCase.names)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,12 +100,10 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ rulestatehistory.Handler }{},
|
||||
struct{ spanmapper.Handler }{},
|
||||
struct{ alertmanager.Handler }{},
|
||||
struct{ alertmanager.Alertmanager }{},
|
||||
struct{ prometheus.Handler }{},
|
||||
struct{ llmpricingrule.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ ruler.Ruler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
global.Config{},
|
||||
|
||||
@@ -48,7 +48,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
|
||||
@@ -265,10 +264,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -333,7 +328,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
|
||||
)
|
||||
}
|
||||
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozapiserver.NewFactory(
|
||||
orgGetter,
|
||||
@@ -370,12 +365,10 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
alertmanagerService,
|
||||
handlers.PrometheusHandler,
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
rulerService,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
globalConfig,
|
||||
|
||||
@@ -106,8 +106,6 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.APIServer,
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addRoutePolicyTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddRoutePolicyTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_route_policy_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addRoutePolicyTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addRoutePolicyTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addRoutePolicyTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
crudVerbs := []string{"create", "read", "update", "delete", "list"}
|
||||
readVerbs := []string{"read", "list"}
|
||||
linkVerbs := []string{"attach", "detach"}
|
||||
|
||||
tuples := make([]migrationTuple, 0, 15)
|
||||
for _, verb := range crudVerbs {
|
||||
tuples = append(tuples, migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "route-policy", verb})
|
||||
}
|
||||
for _, verb := range readVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "route-policy", verb},
|
||||
migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "route-policy", verb},
|
||||
)
|
||||
}
|
||||
for _, verb := range linkVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "route-policy", verb},
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "notification-channel", verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "notification-channel", verb},
|
||||
)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addRoutePolicyTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDowntimeTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDowntimeTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
crudVerbs := []string{"create", "read", "update", "delete", "list"}
|
||||
readVerbs := []string{"read", "list"}
|
||||
linkVerbs := []string{"attach", "detach"}
|
||||
|
||||
tuples := make([]migrationTuple, 0, 20)
|
||||
for _, verb := range crudVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
|
||||
)
|
||||
}
|
||||
for _, verb := range readVerbs {
|
||||
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
|
||||
}
|
||||
for _, objectName := range []string{"planned-maintenance", "rule"} {
|
||||
for _, verb := range linkVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addRuleTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddRuleTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_rule_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addRuleTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addRuleTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addRuleTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
crudVerbs := []string{"create", "read", "update", "delete", "list"}
|
||||
readVerbs := []string{"read", "list"}
|
||||
|
||||
tuples := make([]migrationTuple, 0, 12)
|
||||
for _, verb := range crudVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "rule", verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "rule", verb},
|
||||
)
|
||||
}
|
||||
for _, verb := range readVerbs {
|
||||
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "rule", verb})
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addRuleTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -150,24 +150,6 @@ func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, c
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func MapChannelNamesToIDs(channels Channels, names []string) ([]string, error) {
|
||||
idByName := make(map[string]string, len(channels))
|
||||
for _, channel := range channels {
|
||||
idByName[channel.DisplayName] = channel.ID.StringValue()
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
id, ok := idByName[name]
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "notification channel %q does not exist", name)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
for _, channel := range channels {
|
||||
|
||||
@@ -6,12 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewConfigFromChannels(t *testing.T) {
|
||||
@@ -299,36 +296,3 @@ func TestNewChannelFromReceiverGoogleChat(t *testing.T) {
|
||||
channel.Data,
|
||||
)
|
||||
}
|
||||
|
||||
func TestMapChannelNamesToIDs(t *testing.T) {
|
||||
slackID := valuer.GenerateUUID()
|
||||
pagerID := valuer.GenerateUUID()
|
||||
channels := Channels{
|
||||
{Identifiable: types.Identifiable{ID: slackID}, DisplayName: "slack"},
|
||||
{Identifiable: types.Identifiable{ID: pagerID}, DisplayName: "pager"},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
names []string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "AllKnown_IDsInRequestOrder", names: []string{"pager", "slack"}, want: []string{pagerID.StringValue(), slackID.StringValue()}},
|
||||
{name: "UnknownName_Errors", names: []string{"slack", "missing"}, wantErr: true},
|
||||
{name: "NoNames_Empty", names: nil, want: []string{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got, err := MapChannelNamesToIDs(channels, testCase.names)
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -134,16 +134,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
@@ -176,16 +172,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
// route-policy — admin writes, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
// apdex-setting — admin updates, viewer reads
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindApdexSetting}, WildCardSelectorString)},
|
||||
@@ -249,16 +241,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
@@ -294,8 +282,6 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
// notification-channel — read only (admin writes)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindNotificationChannel}, WildCardSelectorString)},
|
||||
// route-policy — read only (admin writes)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRoutePolicy}, WildCardSelectorString)},
|
||||
|
||||
@@ -48,7 +48,7 @@ var (
|
||||
ResourceRole = NewResourceRole()
|
||||
ResourceServiceAccount = NewResourceServiceAccount()
|
||||
ResourceUser = NewResourceUser()
|
||||
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel)
|
||||
ResourceMetaResourceNotificationChannel = NewResourceMetaResource(KindNotificationChannel, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceRoutePolicy = NewResourceMetaResource(KindRoutePolicy)
|
||||
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
|
||||
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)
|
||||
|
||||
@@ -337,19 +337,6 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
|
||||
return r.validate()
|
||||
}
|
||||
|
||||
func NewPatchedPostableRule(stored, patch []byte) (*PostableRule, error) {
|
||||
patched := new(PostableRule)
|
||||
if err := json.Unmarshal(stored, patched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(patch, patched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return patched, nil
|
||||
}
|
||||
|
||||
func isValidLabelName(ln string) bool {
|
||||
if len(ln) == 0 {
|
||||
return false
|
||||
|
||||
1
tests/fixtures/cloudintegrations.py
vendored
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="")
|
||||
|
||||
@@ -188,9 +188,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -201,9 +199,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -260,9 +256,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -273,9 +267,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -424,9 +416,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -437,9 +427,7 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -504,9 +492,7 @@
|
||||
"kind": "notification-channel",
|
||||
"verbs": [
|
||||
"read",
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import ProviderAccountSpec, simulate_agent_checkin
|
||||
from fixtures.cloudintegrations import ProviderAccountSpec
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -27,14 +27,12 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -93,121 +91,6 @@ def test_create_account(
|
||||
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
|
||||
|
||||
|
||||
def test_create_gcp_manual_account_connection_method(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Manual accounts retain the explicitly selected projects."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
config = {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-test-project", "signoz-test-project-2"],
|
||||
}
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config={"gcp": config})
|
||||
assert account["connectionArtifact"].get("gcp") is None
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == config
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account["id"], "manual-account")
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
assert checkin.json()["data"]["integrationConfig"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_changes,omitted_field,error_field",
|
||||
[
|
||||
pytest.param({}, "connectionMethod", "connectionMethod", id="missing_connection_method"),
|
||||
pytest.param({"connectionMethod": None}, None, "connectionMethod", id="null_connection_method"),
|
||||
pytest.param({"connectionMethod": ""}, None, "connectionMethod", id="empty_connection_method"),
|
||||
pytest.param({"connectionMethod": " "}, None, "connectionMethod", id="blank_connection_method"),
|
||||
pytest.param({"connectionMethod": "unsupported"}, None, "connectionMethod", id="unsupported_connection_method"),
|
||||
*[pytest.param({"connectionMethod": method}, "projectIds", "projectIds", id=f"{method}_missing_project_ids") for method in ("manual", "agent")],
|
||||
*[
|
||||
pytest.param({"connectionMethod": method, "projectIds": project_ids}, None, "projectIds", id=f"{method}_{case}")
|
||||
for method in ("manual", "agent")
|
||||
for case, project_ids in (
|
||||
("null_project_ids", None),
|
||||
("empty_project_ids", []),
|
||||
("empty_project_id", [""]),
|
||||
("blank_project_id", [" "]),
|
||||
("padded_project_id", [" signoz-validation-project "]),
|
||||
)
|
||||
],
|
||||
pytest.param({"connectionMethod": "agent", "projectIds": ["signoz-other-project"]}, None, "projectIds", id="agent_mismatched_project"),
|
||||
pytest.param({"connectionMethod": "agent", "projectIds": ["signoz-validation-project", "signoz-other-project"]}, None, "projectIds", id="agent_multiple_projects"),
|
||||
pytest.param({"deploymentProjectId": " signoz-validation-project "}, None, "deploymentProjectId", id="padded_deployment_project"),
|
||||
],
|
||||
)
|
||||
def test_create_gcp_account_rejects_invalid_config(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
config_changes: dict,
|
||||
omitted_field: str | None,
|
||||
error_field: str,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
config = {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-validation-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-validation-project"],
|
||||
} | config_changes
|
||||
if omitted_field is not None:
|
||||
del config[omitted_field]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {"gcp": config},
|
||||
"credentials": {
|
||||
"sigNozApiUrl": "https://test-deployment.test.signoz.cloud",
|
||||
"sigNozApiKey": "test-api-key-789",
|
||||
"ingestionUrl": "https://ingest.test.signoz.cloud",
|
||||
"ingestionKey": "test-ingestion-key-123456",
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert error_field in response.text
|
||||
|
||||
|
||||
def test_create_gcp_agent_account_connection_command(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
"gcp",
|
||||
config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": "signoz-agent-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-agent-project"]}},
|
||||
)
|
||||
artifact = account["connectionArtifact"]["gcp"]
|
||||
assert set(artifact) == {"cliCommand"}
|
||||
|
||||
command = artifact["cliCommand"]
|
||||
assert command.startswith("curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform")
|
||||
assert "--region 'us-central1'" in command
|
||||
assert "--gcp-project 'signoz-agent-project'" in command
|
||||
assert f"--integration-account-id '{account['id']}'" in command
|
||||
assert "--signoz-api-url 'https://test-deployment.test.signoz.cloud'" in command
|
||||
assert "--signoz-api-key 'test-api-key-789'" in command
|
||||
assert "--ingestion-url 'https://ingest.test.signoz.cloud'" in command
|
||||
assert "--ingestion-key 'test-ingestion-key-123456'" in command
|
||||
assert "--agent-version 'v0.0.8'" in command
|
||||
|
||||
|
||||
def test_create_account_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -252,7 +135,6 @@ def test_create_gcp_account_without_project_ids(
|
||||
json={
|
||||
"config": {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [],
|
||||
|
||||
@@ -31,14 +31,12 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -53,25 +51,6 @@ provider_spec = pytest.mark.parametrize(
|
||||
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
|
||||
)
|
||||
|
||||
provider_account_reuse = pytest.mark.parametrize(
|
||||
"provider,account_config,provider_account_id",
|
||||
[
|
||||
pytest.param("aws", {"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}}, "123456789012", id="aws"),
|
||||
pytest.param(
|
||||
"gcp",
|
||||
{"gcp": {"connectionMethod": "manual", "deploymentProjectId": "signoz-reuse-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-reuse-project"]}},
|
||||
"reused-manual-account",
|
||||
id="gcp_manual",
|
||||
),
|
||||
pytest.param(
|
||||
"gcp",
|
||||
{"gcp": {"connectionMethod": "agent", "deploymentProjectId": "signoz-reuse-project", "deploymentRegion": "us-central1", "projectIds": ["signoz-reuse-project"]}},
|
||||
"signoz-reuse-project",
|
||||
id="gcp_agent",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
@@ -176,201 +155,6 @@ def test_duplicate_cloud_account_checkins(
|
||||
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_gcp_agent_check_in_returns_collection_strategy(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
project_id = "signoz-agent-strategy-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
services_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services")
|
||||
mysql_sink_filter = '(resource.type="cloudsql_database" AND (logName:"/logs/cloudsql.googleapis.com%2Fmysql" OR (log_id("cloudaudit.googleapis.com/data_access") AND protoPayload.request.@type="type.googleapis.com/google.cloud.sql.audit.v1.MysqlAuditEntry")))'
|
||||
postgres_sink_filter = '(resource.type="cloudsql_database" AND (logName:"/logs/cloudsql.googleapis.com%2Fpostgres" OR (log_id("cloudaudit.googleapis.com/data_access") AND protoPayload.request.@type="type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry")))'
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["integrationConfig"]["gcp"] == {
|
||||
"connectionMethod": "agent",
|
||||
"deploymentProjectId": project_id,
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [project_id],
|
||||
"telemetryCollectionStrategy": {"metrics": None, "logs": None},
|
||||
}
|
||||
|
||||
for service_id, service_config in (
|
||||
("cloudsql_mysql", {"metrics": {"enabled": True}, "logs": {"enabled": False}}),
|
||||
("cloudsql_postgres", {"metrics": {"enabled": True}, "logs": {"enabled": True}}),
|
||||
):
|
||||
response = requests.put(f"{services_url}/{service_id}", headers=headers, json={"config": {"gcp": service_config}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
strategy = response.json()["data"]["integrationConfig"]["gcp"]["telemetryCollectionStrategy"]
|
||||
metric_names = strategy["metrics"]["metricNames"]
|
||||
assert metric_names == sorted(set(metric_names)), "metrics shared by MySQL and Postgres should be listed once, sorted"
|
||||
assert {"cloudsql.googleapis.com/database/up", "cloudsql.googleapis.com/database/mysql/queries", "cloudsql.googleapis.com/database/postgresql/num_backends"} <= set(metric_names)
|
||||
assert strategy["logs"] == {"sinkFilter": postgres_sink_filter}, "only services with logs enabled should contribute a sink filter"
|
||||
|
||||
response = requests.put(f"{services_url}/cloudsql_mysql", headers=headers, json={"config": {"gcp": {"metrics": {"enabled": False}, "logs": {"enabled": True}}}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
strategy = response.json()["data"]["integrationConfig"]["gcp"]["telemetryCollectionStrategy"]
|
||||
assert "cloudsql.googleapis.com/database/up" in strategy["metrics"]["metricNames"], "Postgres should still collect the shared metrics"
|
||||
assert not [name for name in strategy["metrics"]["metricNames"] if "/database/mysql/" in name], "MySQL metrics should stop once its metrics are disabled"
|
||||
assert strategy["logs"] == {"sinkFilter": f"{mysql_sink_filter} OR {postgres_sink_filter}"}
|
||||
|
||||
|
||||
def test_gcp_agent_check_in_rejects_other_project(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
project_id = "signoz-agent-mismatch-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, "signoz-other-project")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["providerAccountId"] is None, "a rejected check-in must not connect the account"
|
||||
assert response.json()["data"]["agentReport"] is None
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
@provider_account_reuse
|
||||
def test_removed_account_checkins_after_provider_account_id_is_reused(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
provider: str,
|
||||
account_config: dict,
|
||||
provider_account_id: str,
|
||||
) -> None:
|
||||
"""Connect old account, remove it, connect replacement, then check in from the old agent."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
accounts_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{provider}/accounts")
|
||||
|
||||
# The original agent connects before its integration is removed.
|
||||
old_account_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id, data={"version": "old-agent"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.delete(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
old_account_before_checkins = response.json()["data"]
|
||||
|
||||
# A new integration claims the same provider account ID.
|
||||
replacement_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id, data={"version": "replacement-agent"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
replacement_before_checkins = response.json()["data"]
|
||||
|
||||
# The old agent keeps retrying. It must receive removal instructions, rather than a 409.
|
||||
for _ in range(3):
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id, data={"version": "old-agent-after-removal"})
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
cleanup_response = response.json()["data"]
|
||||
assert cleanup_response["cloudIntegrationId"] == old_account_id
|
||||
assert cleanup_response["providerAccountId"] == provider_account_id
|
||||
assert cleanup_response["removedAt"] is not None
|
||||
assert cleanup_response["removedAt"] == old_account_before_checkins["removedAt"]
|
||||
assert cleanup_response["integrationConfig"] == {}
|
||||
|
||||
# Cleanup may update sync state, but must preserve the original heartbeat, data and identity.
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
removed_account = response.json()["data"]
|
||||
assert removed_account["providerAccountId"] == old_account_before_checkins["providerAccountId"]
|
||||
assert removed_account["agentReport"]["timestampMillis"] == old_account_before_checkins["agentReport"]["timestampMillis"]
|
||||
assert removed_account["agentReport"]["data"] == old_account_before_checkins["agentReport"]["data"]
|
||||
assert removed_account["agentReport"]["syncState"] == cleanup_response["syncState"]
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == replacement_before_checkins, "old check-ins must not change the replacement account"
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["removedAt"] is None
|
||||
|
||||
|
||||
@provider_account_reuse
|
||||
def test_removed_account_first_checkin_after_provider_account_id_is_reused(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
provider: str,
|
||||
account_config: dict,
|
||||
provider_account_id: str,
|
||||
) -> None:
|
||||
"""Removal before the first heartbeat must still be reported after another integration claims the provider ID."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
accounts_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{provider}/accounts")
|
||||
|
||||
# Remove the original integration before its agent has checked in even once.
|
||||
old_account_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = requests.delete(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
old_account_before_checkins = response.json()["data"]
|
||||
assert old_account_before_checkins["providerAccountId"] is None
|
||||
assert old_account_before_checkins["agentReport"] is None
|
||||
|
||||
replacement_id = create_cloud_integration_account(admin_token, provider, config=account_config)["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
replacement_before_checkins = response.json()["data"]
|
||||
|
||||
# Late first check-ins must report removal without creating a heartbeat or claiming the ID.
|
||||
for _ in range(3):
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, old_account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
cleanup_response = response.json()["data"]
|
||||
assert cleanup_response["cloudIntegrationId"] == old_account_id
|
||||
assert cleanup_response["providerAccountId"] == provider_account_id
|
||||
assert cleanup_response["removedAt"] == old_account_before_checkins["removedAt"]
|
||||
assert cleanup_response["removedAt"] is not None
|
||||
assert cleanup_response["integrationConfig"] == {}
|
||||
|
||||
response = requests.get(f"{accounts_url}/{old_account_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == old_account_before_checkins, "late first check-ins must not change a removed account"
|
||||
|
||||
response = requests.get(f"{accounts_url}/{replacement_id}", headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == replacement_before_checkins
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, provider, replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["removedAt"] is None
|
||||
|
||||
|
||||
def test_sync_state_drops_removed_region_after_ack(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -562,13 +346,12 @@ def test_sync_state_region_removed_during_sync(
|
||||
}
|
||||
|
||||
|
||||
def test_sync_state_after_disconnect_when_provider_account_id_is_reused(
|
||||
def test_sync_state_after_disconnect(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""The old AWS agent can finish cleanup while its provider ID belongs to a replacement integration."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
@@ -583,10 +366,6 @@ def test_sync_state_after_disconnect_when_provider_account_id_is_reused(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
replacement_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", replacement_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
for _ in range(2):
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
@@ -601,11 +380,3 @@ def test_sync_state_after_disconnect_when_provider_account_id_is_reused(
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=2)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {"version": 2, "inSync": True, "regions": {}}, "acked removal should leave no regions"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["agentReport"]["syncState"] == {"version": 2, "inSync": True, "regions": {}}, "cleanup acknowledgment must be saved"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
@@ -44,14 +42,12 @@ GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
@@ -77,47 +73,6 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_gcp_account_without_connection_method_remains_manual(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""A stored manual account without the new field can still be read, connected and updated."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
project_id = "signoz-test-project"
|
||||
config = {"deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config={"gcp": config | {"connectionMethod": "manual"}})
|
||||
|
||||
# Reproduce the released manual account format, which had no connection method.
|
||||
with signoz.sqlstore.conn.begin() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE cloud_integration SET config=:config WHERE id=:id"),
|
||||
{"config": json.dumps(config), "id": account["id"]},
|
||||
)
|
||||
|
||||
account_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account['id']}")
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
response = requests.get(
|
||||
account_url,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == config | {"connectionMethod": "manual"}
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account["id"], project_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
assert checkin.json()["data"]["integrationConfig"] == {}
|
||||
|
||||
updated_config = config | {"projectIds": [project_id, "signoz-test-project-2"]}
|
||||
response = requests.put(account_url, headers=headers, json={"config": {"gcp": updated_config}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
stored = conn.execute(sql.text("SELECT config FROM cloud_integration WHERE id=:id"), {"id": account["id"]}).scalar_one()
|
||||
assert json.loads(stored) == updated_config | {"connectionMethod": "manual"}
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_accounts_empty(
|
||||
signoz: types.SigNoz,
|
||||
@@ -338,34 +293,6 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
assert found_after["removedAt"] is None, "removedAt should still be null"
|
||||
|
||||
|
||||
def test_update_gcp_agent_account_rejected(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
project_id = "signoz-agent-update-project"
|
||||
account_id = create_cloud_integration_account(admin_token, "gcp", config={"gcp": {"connectionMethod": "agent", "deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id]}})["id"]
|
||||
account_url = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}")
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, project_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, checkin.text
|
||||
|
||||
response = requests.put(account_url, headers=headers, json={"config": {"gcp": {"deploymentProjectId": project_id, "deploymentRegion": "us-central1", "projectIds": [project_id, "signoz-agent-update-project-2"]}}}, timeout=10)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.get(account_url, headers=headers, timeout=10)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["gcp"] == {
|
||||
"connectionMethod": "agent",
|
||||
"deploymentProjectId": project_id,
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [project_id],
|
||||
}, "a rejected update must not change the agent account config"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_disconnect_account(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
@@ -27,7 +27,6 @@ GCP_SERVICE_SPEC = ProviderServiceSpec(
|
||||
service_id="cloudsql_postgres",
|
||||
account_config={
|
||||
"gcp": {
|
||||
"connectionMethod": "manual",
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-test-project"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user