mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-10 06:00:19 +01:00
Compare commits
102 Commits
feat/trace
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8924f96bb2 | ||
|
|
a52155886b | ||
|
|
4a953a4998 | ||
|
|
1bb909ae7d | ||
|
|
88a5d03a7f | ||
|
|
92218ccf68 | ||
|
|
45d4974c38 | ||
|
|
303aa282dc | ||
|
|
950830d193 | ||
|
|
b39b3970b6 | ||
|
|
a539983a4c | ||
|
|
861bbf1ec8 | ||
|
|
8158a85e86 | ||
|
|
b7d7a5422e | ||
|
|
74b7f4b4e8 | ||
|
|
985d66539a | ||
|
|
b15f817ba3 | ||
|
|
5e1cf14de9 | ||
|
|
99944b5f92 | ||
|
|
f8eda16533 | ||
|
|
a2eb8ab00a | ||
|
|
601007cba1 | ||
|
|
925a29d2df | ||
|
|
d54fc50236 | ||
|
|
a2ad5b1172 | ||
|
|
802a11ee2b | ||
|
|
a8124f6e73 | ||
|
|
8811aaefe8 | ||
|
|
66aaaea918 | ||
|
|
900c489d91 | ||
|
|
743fe56523 | ||
|
|
3a9e93ebdf | ||
|
|
cdbb78a93d | ||
|
|
c11186f7bf | ||
|
|
51dbb0b5b9 | ||
|
|
2545d7df61 | ||
|
|
3f91821825 | ||
|
|
ee5d182539 | ||
|
|
0bc12f02bc | ||
|
|
e5f00421fe | ||
|
|
539252e10c | ||
|
|
d65f426254 | ||
|
|
6e52f2c8f0 | ||
|
|
d9f8a4ae5a | ||
|
|
eefe3edffd | ||
|
|
2051861a03 | ||
|
|
4b01a40fb9 | ||
|
|
2d8a00bf18 | ||
|
|
f1b26b310f | ||
|
|
2c438b6c32 | ||
|
|
1814c2d13c | ||
|
|
e6cd771f11 | ||
|
|
6b94f87ca0 | ||
|
|
bf315253ae | ||
|
|
668ff7bc39 | ||
|
|
07f2aa52fd | ||
|
|
3416b3ad55 | ||
|
|
d6caa4f2c7 | ||
|
|
f86371566d | ||
|
|
9115803084 | ||
|
|
0c14d8f966 | ||
|
|
7afb461af8 | ||
|
|
a21fbb4ee0 | ||
|
|
0369842f3d | ||
|
|
59cd96562a | ||
|
|
cc4475cab7 | ||
|
|
ac8c648420 | ||
|
|
bede6be4b8 | ||
|
|
dd3d60e6df | ||
|
|
538ab686d2 | ||
|
|
936a325cb9 | ||
|
|
c6cdcd0143 | ||
|
|
cd9211d718 | ||
|
|
0601c28782 | ||
|
|
580610dbfa | ||
|
|
2d2aa02a81 | ||
|
|
dd9723ad13 | ||
|
|
3651469416 | ||
|
|
febce75734 | ||
|
|
e1616f3487 | ||
|
|
4b94287ac7 | ||
|
|
1575c7c54c | ||
|
|
8def3f835b | ||
|
|
11ed15f4c5 | ||
|
|
f47877cca9 | ||
|
|
bb2b9215ba | ||
|
|
3111904223 | ||
|
|
003e2c30d8 | ||
|
|
00fe516d10 | ||
|
|
0305f4f7db | ||
|
|
c60019a6dc | ||
|
|
acde2a37fa | ||
|
|
945241a52a | ||
|
|
e967f80c86 | ||
|
|
a09dc325de | ||
|
|
379b4f7fc4 | ||
|
|
5e536ae077 | ||
|
|
234585e642 | ||
|
|
2cc14f1ad4 | ||
|
|
dc4ed4d239 | ||
|
|
7281c36873 | ||
|
|
40288776e8 |
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
|
||||
@@ -18,11 +17,15 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -30,6 +33,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
|
||||
@@ -94,12 +98,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
},
|
||||
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
return signoz.NewAuditorProviderFactories()
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
func(_ cloudintegrationtypes.Store, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
|
||||
@@ -17,6 +16,8 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
@@ -25,16 +26,19 @@ import (
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
@@ -42,6 +46,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
@@ -127,7 +132,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
|
||||
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
@@ -135,19 +139,25 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
factories := signoz.NewAuditorProviderFactories()
|
||||
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
},
|
||||
)
|
||||
func(store cloudintegrationtypes.Store, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
defStore := pkgcloudintegration.NewServiceDefinitionStore()
|
||||
awsCloudProviderModule, err := implcloudprovider.NewAWSCloudProvider(defStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
|
||||
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
|
||||
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(store, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
return err
|
||||
|
||||
@@ -365,33 +365,9 @@ serviceaccount:
|
||||
# toggle service account analytics
|
||||
enabled: true
|
||||
|
||||
##################### Auditor #####################
|
||||
auditor:
|
||||
# Specifies the auditor provider to use.
|
||||
# noop: discards all audit events (community default).
|
||||
# otlphttp: exports audit events via OTLP HTTP (enterprise).
|
||||
provider: noop
|
||||
# The async channel capacity for audit events. Events are dropped when full (fail-open).
|
||||
buffer_size: 1000
|
||||
# The maximum number of events per export batch.
|
||||
batch_size: 100
|
||||
# The maximum time between export flushes.
|
||||
flush_interval: 1s
|
||||
otlphttp:
|
||||
# The target scheme://host:port/path of the OTLP HTTP endpoint.
|
||||
endpoint: http://localhost:4318/v1/logs
|
||||
# Whether to use HTTP instead of HTTPS.
|
||||
insecure: false
|
||||
# The maximum duration for an export attempt.
|
||||
timeout: 10s
|
||||
# Additional HTTP headers sent with every export request.
|
||||
headers: {}
|
||||
retry:
|
||||
# Whether to retry on transient failures.
|
||||
enabled: true
|
||||
# The initial wait time before the first retry.
|
||||
initial_interval: 5s
|
||||
# The upper bound on backoff interval.
|
||||
max_interval: 30s
|
||||
# The total maximum time spent retrying.
|
||||
max_elapsed_time: 60s
|
||||
##################### Cloud Integration #####################
|
||||
cloudintegration:
|
||||
# cloud integration agent configuration
|
||||
agent:
|
||||
# The version of the cloud integration agent.
|
||||
version: v0.0.8
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type awscloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) (cloudintegration.CloudProviderModule, error) {
|
||||
return &awscloudprovider{serviceDefinitions: defStore}, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.Aws.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
q = u.Query()
|
||||
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
|
||||
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
|
||||
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
|
||||
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
|
||||
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
|
||||
q.Set("param_SigNozAccountId", account.ID.StringValue())
|
||||
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
|
||||
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return provider.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
serviceDef, err := provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAWS, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// override cloud integration dashboard id
|
||||
for index, dashboard := range serviceDef.Assets.Dashboards {
|
||||
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAWS, serviceID.StringValue(), dashboard.ID)
|
||||
}
|
||||
|
||||
return serviceDef, nil
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// Sort services for deterministic output
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].Type.StringValue() < services[j].Type.StringValue()
|
||||
})
|
||||
|
||||
compiledMetrics := new(cloudintegrationtypes.AWSMetricsCollectionStrategy)
|
||||
compiledLogs := new(cloudintegrationtypes.AWSLogsCollectionStrategy)
|
||||
var compiledS3Buckets map[string][]string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeAWS, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.AWS
|
||||
logsEnabled := svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
|
||||
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
|
||||
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
|
||||
if logsEnabled && svcCfg.AWS.Logs.S3Buckets != nil {
|
||||
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
|
||||
}
|
||||
// no need to go ahead as the code block specifically checks for the S3Sync service
|
||||
continue
|
||||
}
|
||||
|
||||
if logsEnabled && strategy.Logs != nil {
|
||||
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
|
||||
}
|
||||
|
||||
metricsEnabled := svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
|
||||
|
||||
if metricsEnabled && strategy.Metrics != nil {
|
||||
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
|
||||
}
|
||||
}
|
||||
|
||||
collectionStrategy := new(cloudintegrationtypes.AWSTelemetryCollectionStrategy)
|
||||
|
||||
if len(compiledMetrics.StreamFilters) > 0 {
|
||||
collectionStrategy.Metrics = compiledMetrics
|
||||
}
|
||||
if len(compiledLogs.Subscriptions) > 0 {
|
||||
collectionStrategy.Logs = compiledLogs
|
||||
}
|
||||
if compiledS3Buckets != nil {
|
||||
collectionStrategy.S3Buckets = compiledS3Buckets
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
|
||||
EnabledRegions: account.Config.AWS.Regions,
|
||||
TelemetryCollectionStrategy: collectionStrategy,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type azurecloudprovider struct{}
|
||||
|
||||
func NewAzureCloudProvider() cloudintegration.CloudProviderModule {
|
||||
return &azurecloudprovider{}
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (provider *azurecloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
panic("implement me")
|
||||
}
|
||||
513
ee/modules/cloudintegration/implcloudintegration/module.go
Normal file
513
ee/modules/cloudintegration/implcloudintegration/module.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store cloudintegrationtypes.Store
|
||||
gateway gateway.Gateway
|
||||
zeus zeus.Zeus
|
||||
licensing licensing.Licensing
|
||||
global global.Global
|
||||
serviceAccount serviceaccount.Module
|
||||
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
|
||||
config cloudintegration.Config
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
store cloudintegrationtypes.Store,
|
||||
global global.Global,
|
||||
zeus zeus.Zeus,
|
||||
gateway gateway.Gateway,
|
||||
licensing licensing.Licensing,
|
||||
serviceAccount serviceaccount.Module,
|
||||
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule,
|
||||
config cloudintegration.Config,
|
||||
) (cloudintegration.Module, error) {
|
||||
return &module{
|
||||
store: store,
|
||||
global: global,
|
||||
zeus: zeus,
|
||||
gateway: gateway,
|
||||
licensing: licensing,
|
||||
serviceAccount: serviceAccount,
|
||||
cloudProvidersMap: cloudProvidersMap,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetConnectionCredentials returns credentials required to generate connection artifact. eg. apiKey, ingestionKey etc.
|
||||
// It will return creds it can deduce and return empty value for others.
|
||||
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
|
||||
// get license to get the deployment details
|
||||
license, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
var ingestionURL string
|
||||
|
||||
globalConfig := module.global.GetConfig(ctx)
|
||||
if globalConfig != nil {
|
||||
ingestionURL = globalConfig.IngestionURL
|
||||
}
|
||||
|
||||
// get deployment details from zeus, ignore error
|
||||
respBytes, _ := module.zeus.GetDeployment(ctx, license.Key)
|
||||
|
||||
var signozAPIURL string
|
||||
|
||||
if len(respBytes) > 0 {
|
||||
// parse deployment details, ignore error, if client is asking api url every time check for possible error
|
||||
deployment, _ := zeustypes.NewGettableDeployment(respBytes)
|
||||
if deployment != nil {
|
||||
signozAPIURL = deployment.SignozAPIUrl
|
||||
}
|
||||
}
|
||||
|
||||
// ignore error
|
||||
apiKey, _ := module.getOrCreateAPIKey(ctx, orgID, provider)
|
||||
|
||||
// ignore error
|
||||
ingestionKey, _ := module.getOrCreateIngestionKey(ctx, orgID, provider)
|
||||
|
||||
return &cloudintegrationtypes.Credentials{
|
||||
SigNozAPIURL: signozAPIURL,
|
||||
SigNozAPIKey: apiKey,
|
||||
IngestionURL: ingestionURL,
|
||||
IngestionKey: ingestionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
_, err := module.licensing.GetActive(ctx, account.OrgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableCloudIntegration, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.CreateAccount(ctx, storableCloudIntegration)
|
||||
}
|
||||
|
||||
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
cloudProviderModule, err := module.getCloudProvider(account.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Config.AddAgentVersion(module.config.Agent.Version)
|
||||
|
||||
return cloudProviderModule.GetConnectionArtifact(ctx, account, req)
|
||||
}
|
||||
|
||||
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, accountID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
}
|
||||
|
||||
// ListAccounts return only agent connected accounts.
|
||||
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAccountsFromStorables(storableAccounts)
|
||||
}
|
||||
|
||||
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
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.GetConnectedAccount(ctx, orgID, provider, req.ProviderAccountID)
|
||||
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)
|
||||
}
|
||||
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent doesn't act on config for removed accounts.
|
||||
if account.RemovedAt != nil {
|
||||
return &cloudintegrationtypes.AgentCheckInResponse{
|
||||
CloudIntegrationID: account.ID.StringValue(),
|
||||
ProviderAccountID: req.ProviderAccountID,
|
||||
IntegrationConfig: new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
RemovedAt: account.RemovedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedServices, err := module.store.ListServices(ctx, req.CloudIntegrationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.AgentCheckInResponse{
|
||||
CloudIntegrationID: account.ID.StringValue(),
|
||||
ProviderAccountID: req.ProviderAccountID,
|
||||
IntegrationConfig: integrationConfig,
|
||||
RemovedAt: account.RemovedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
_, err := module.licensing.GetActive(ctx, account.OrgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.UpdateAccount(ctx, storableAccount)
|
||||
}
|
||||
|
||||
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
|
||||
}
|
||||
|
||||
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID *valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceDefinitions, err := cloudProvider.ListServiceDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enabledServiceIDs := map[string]bool{}
|
||||
if integrationID != nil {
|
||||
storedServices, err := module.store.ListServices(ctx, *integrationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, svc := range storedServices {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, svc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if serviceConfig.IsServiceEnabled(provider) {
|
||||
enabledServiceIDs[svc.Type.StringValue()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := make([]*cloudintegrationtypes.ServiceMetadata, 0, len(serviceDefinitions))
|
||||
for _, serviceDefinition := range serviceDefinitions {
|
||||
resp = append(resp, cloudintegrationtypes.NewServiceMetadata(*serviceDefinition, enabledServiceIDs[serviceDefinition.ID]))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Service, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var integrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
if integrationID != nil {
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, *integrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService != nil {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, service.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configJSON, err := service.Config.ToJSON(provider, service.Type, &serviceDefinition.SupportedSignals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, string(configJSON)))
|
||||
}
|
||||
|
||||
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, integrationService.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configJSON, err := integrationService.Config.ToJSON(provider, integrationService.Type, &serviceDefinition.SupportedSignals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, string(configJSON))
|
||||
|
||||
return module.store.UpdateService(ctx, storableService)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
_, _, _, err = cloudintegrationtypes.ParseCloudIntegrationDashboardID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allDashboards, err := module.listDashboards(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range allDashboards {
|
||||
if d.ID == id {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration dashboard not found")
|
||||
}
|
||||
|
||||
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.listDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
}
|
||||
|
||||
return nil, errors.NewInvalidInputf(cloudintegrationtypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
|
||||
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
|
||||
keyName := cloudintegrationtypes.NewIngestionKeyName(provider)
|
||||
|
||||
result, err := module.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't search ingestion keys")
|
||||
}
|
||||
|
||||
// ideally there should be only one key per cloud integration provider
|
||||
if len(result.Keys) > 0 {
|
||||
return result.Keys[0].Value, nil
|
||||
}
|
||||
|
||||
createdIngestionKey, err := module.gateway.CreateIngestionKey(ctx, orgID, keyName, []string{"integration"}, time.Time{})
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't create ingestion key")
|
||||
}
|
||||
|
||||
return createdIngestionKey.Value, nil
|
||||
}
|
||||
|
||||
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
|
||||
domain := module.serviceAccount.Config().Email.Domain
|
||||
serviceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgID)
|
||||
serviceAccount, err := module.serviceAccount.GetOrCreate(ctx, orgID, serviceAccount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
factorAPIKey, err := serviceAccount.NewFactorAPIKey(provider.StringValue(), 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
factorAPIKey, err = module.serviceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return factorAPIKey.Key, nil
|
||||
}
|
||||
|
||||
func (module *module) listDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
var allDashboards []*dashboardtypes.Dashboard
|
||||
|
||||
for provider := range module.cloudProvidersMap {
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectedAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, storableAccount := range connectedAccounts {
|
||||
storedServices, err := module.store.ListServices(ctx, storableAccount.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, storedSvc := range storedServices {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedSvc.Config)
|
||||
if err != nil || !serviceConfig.IsMetricsEnabled(provider) {
|
||||
continue
|
||||
}
|
||||
|
||||
svcDef, err := cloudProvider.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil || svcDef == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dashboards := cloudintegrationtypes.GetDashboardsFromAssets(
|
||||
storedSvc.Type.StringValue(),
|
||||
orgID,
|
||||
provider,
|
||||
storableAccount.CreatedAt,
|
||||
svcDef.Assets,
|
||||
)
|
||||
allDashboards = append(allDashboards, dashboards...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(allDashboards, func(i, j int) bool {
|
||||
return allDashboards[i].ID < allDashboards[j].ID
|
||||
})
|
||||
|
||||
return allDashboards, nil
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
|
||||
@@ -139,12 +139,10 @@
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-json-tree": "0.20.0",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
"react-query": "3.39.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-rnd": "10.5.3",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom-v5-compat": "6.27.0",
|
||||
"react-syntax-highlighter": "15.5.0",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
.details-header {
|
||||
// ghost + secondary missing hover bg token in @signozhq/button
|
||||
--button-ghost-hover-background: var(--l3-background);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&__icon-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import './DetailsHeader.styles.scss';
|
||||
|
||||
export interface HeaderAction {
|
||||
key: string;
|
||||
component: ReactNode; // check later if we can use direct btn itself or not.
|
||||
}
|
||||
|
||||
export interface DetailsHeaderProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
actions?: HeaderAction[];
|
||||
closePosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsHeader({
|
||||
title,
|
||||
onClose,
|
||||
actions,
|
||||
closePosition = 'right',
|
||||
className,
|
||||
}: DetailsHeaderProps): JSX.Element {
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="details-header__icon-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`details-header ${className || ''}`}>
|
||||
{closePosition === 'left' && closeButton}
|
||||
|
||||
<span className="details-header__title">{title}</span>
|
||||
|
||||
{actions && (
|
||||
<div className="details-header__actions">
|
||||
{actions.map((action) => (
|
||||
<div key={action.key}>{action.component}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closePosition === 'right' && closeButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsHeader;
|
||||
@@ -1,7 +0,0 @@
|
||||
.details-panel-drawer {
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
|
||||
import './DetailsPanelDrawer.styles.scss';
|
||||
|
||||
interface DetailsPanelDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DetailsPanelDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: DetailsPanelDrawerProps): JSX.Element {
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
className={`details-panel-drawer ${className || ''}`}
|
||||
content={<div className="details-panel-drawer__body">{children}</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailsPanelDrawer;
|
||||
@@ -1,8 +0,0 @@
|
||||
export type {
|
||||
DetailsHeaderProps,
|
||||
HeaderAction,
|
||||
} from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
|
||||
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
|
||||
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
export { default as useDetailsPanel } from './useDetailsPanel';
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface DetailsPanelState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface UseDetailsPanelOptions {
|
||||
entityId: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
|
||||
|
||||
function useDetailsPanel({
|
||||
entityId,
|
||||
onClose,
|
||||
}: UseDetailsPanelOptions): DetailsPanelState {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const prevEntityIdRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const currentId = entityId || '';
|
||||
if (currentId && currentId !== prevEntityIdRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
prevEntityIdRef.current = currentId;
|
||||
}, [entityId]);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
return { isOpen, open, close };
|
||||
}
|
||||
|
||||
export default useDetailsPanel;
|
||||
@@ -677,18 +677,6 @@ function NewWidget({
|
||||
queryType: currentQuery.queryType,
|
||||
isNewPanel,
|
||||
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
|
||||
...(currentQuery.queryType === EQueryType.CLICKHOUSE && {
|
||||
clickhouseQueryCount: currentQuery.clickhouse_sql.length,
|
||||
clickhouseQueries: currentQuery.clickhouse_sql.map((q) => ({
|
||||
name: q.name,
|
||||
query: (q.query ?? '')
|
||||
.replace(/--[^\n]*/g, '') // strip line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
||||
.replace(/'(?:[^'\\]|\\.|'')*'/g, "'?'") // replace single-quoted strings (handles \' and '' escapes)
|
||||
.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, '?'), // replace numeric literals (int, float, scientific)
|
||||
disabled: q.disabled,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
setSaveModal(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
.data-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
// Toolbar — view mode switcher + copy button
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&__mode-select {
|
||||
min-width: 90px;
|
||||
height: 28px !important;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 4px !important;
|
||||
border-color: var(--l2-border) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared content container — no scroll, each view handles its own
|
||||
&__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Copy } from '@signozhq/icons';
|
||||
// TODO: Replace antd Select with @signozhq/ui component when moving to design library
|
||||
import { Select } from 'antd';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import { copyToClipboard } from './utils';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
export interface DataViewerProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: Record<string, any>;
|
||||
drawerKey?: string;
|
||||
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
|
||||
}
|
||||
|
||||
function DataViewer({
|
||||
data,
|
||||
drawerKey = 'default',
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
return (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer__toolbar">
|
||||
<Select
|
||||
className="data-viewer__mode-select"
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={(value: ViewMode): void => setViewMode(value)}
|
||||
options={[
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="data-viewer__copy-btn"
|
||||
onClick={(): void => copyToClipboard(data)}
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
{viewMode === 'pretty' && (
|
||||
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
|
||||
)}
|
||||
{viewMode === 'json' && <JsonView data={jsonString} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataViewer;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { DataViewerProps } from './DataViewer';
|
||||
export { default as DataViewer } from './DataViewer';
|
||||
@@ -1,12 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
|
||||
export function copyToClipboard(value: unknown): void {
|
||||
const text =
|
||||
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.floating-panel {
|
||||
z-index: 999;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
|
||||
// Inner div fills the Rnd-controlled size
|
||||
&__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Drag handle — any child with this class becomes the drag target
|
||||
.floating-panel__drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
import './FloatingPanel.styles.scss';
|
||||
|
||||
type EnableResizing = ComponentProps<typeof Rnd>['enableResizing'];
|
||||
|
||||
export interface FloatingPanelProps {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
width?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
enableResizing?: EnableResizing;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FloatingPanel({
|
||||
isOpen,
|
||||
children,
|
||||
defaultPosition,
|
||||
width = 560,
|
||||
height = 600,
|
||||
minWidth = 400,
|
||||
minHeight = 300,
|
||||
enableResizing,
|
||||
className,
|
||||
}: FloatingPanelProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialPosition = defaultPosition || {
|
||||
x: window.innerWidth - width - 24,
|
||||
y: 80,
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<Rnd
|
||||
default={{
|
||||
x: initialPosition.x,
|
||||
y: initialPosition.y,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
dragHandleClassName="floating-panel__drag-handle"
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
onDrag={(_e, d): void | false => {
|
||||
const HEADER_HEIGHT = 40;
|
||||
// Top: don't allow header to go above viewport
|
||||
if (d.y < 0) {
|
||||
return false;
|
||||
}
|
||||
// Left: don't allow panel to go off-screen left
|
||||
if (d.x < 0) {
|
||||
return false;
|
||||
}
|
||||
// Bottom: only header needs to be visible
|
||||
if (d.y > window.innerHeight - HEADER_HEIGHT) {
|
||||
return false;
|
||||
}
|
||||
// Right: at least the close button (~40px) stays visible
|
||||
if (d.x > window.innerWidth - 40) {
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
className={`floating-panel ${className || ''}`}
|
||||
enableResizing={enableResizing}
|
||||
>
|
||||
<div className="floating-panel__inner">{children}</div>
|
||||
</Rnd>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingPanel;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { FloatingPanelProps } from './FloatingPanel';
|
||||
export { default as FloatingPanel } from './FloatingPanel';
|
||||
@@ -1,22 +0,0 @@
|
||||
.json-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__footer {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&__wrap-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import './JsonView.styles.scss';
|
||||
|
||||
export interface JsonViewProps {
|
||||
data: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const editorOptions: EditorProps['options'] = {
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontWeight: 400,
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
decorationsOverviewRuler: false,
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
|
||||
folding: false,
|
||||
};
|
||||
|
||||
function setEditorTheme(monaco: Monaco): void {
|
||||
monaco.editor.defineTheme('signoz-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
|
||||
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#00000000', // transparent
|
||||
},
|
||||
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.06,
|
||||
});
|
||||
}
|
||||
|
||||
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState(true);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className="json-view">
|
||||
<MEditor
|
||||
value={data}
|
||||
language="json"
|
||||
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
|
||||
onChange={(): void => {}}
|
||||
height={height}
|
||||
theme={isDarkMode ? 'signoz-dark' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
/>
|
||||
<div className="json-view__footer">
|
||||
<div className="json-view__wrap-toggle">
|
||||
<Typography.Text>Wrap text</Typography.Text>
|
||||
<Switch
|
||||
checked={isWrapWord}
|
||||
onChange={(checked): void => setIsWrapWord(checked)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonView;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { JsonViewProps } from './JsonView';
|
||||
export { default as JsonView } from './JsonView';
|
||||
@@ -1,67 +1,43 @@
|
||||
.key-value-label {
|
||||
display: flex;
|
||||
border: 1px solid var(--l2-border);
|
||||
align-items: center;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 2px;
|
||||
|
||||
&--row {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--column {
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
|
||||
.key-value-label__key {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.48px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.key-value-label__value {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__key,
|
||||
&__value {
|
||||
padding: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.005em;
|
||||
&,
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__key {
|
||||
background: var(--l2-background);
|
||||
background: var(--bg-ink-400);
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
background: var(--l3-background);
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.lightMode {
|
||||
.key-value-label {
|
||||
border-color: var(--bg-vanilla-400);
|
||||
&__key,
|
||||
&__value {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
&__key {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
&__value {
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,29 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import TrimmedText from '../TrimmedText/TrimmedText';
|
||||
|
||||
import './KeyValueLabel.styles.scss';
|
||||
|
||||
// Rethink this component later
|
||||
type KeyValueLabelProps = {
|
||||
badgeKey: string | React.ReactNode;
|
||||
badgeValue: string | React.ReactNode;
|
||||
direction?: 'row' | 'column';
|
||||
badgeValue: string;
|
||||
maxCharacters?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function KeyValueLabel({
|
||||
badgeKey,
|
||||
badgeValue,
|
||||
direction = 'row',
|
||||
maxCharacters = 20,
|
||||
onClick,
|
||||
}: KeyValueLabelProps): JSX.Element | null {
|
||||
const isUrl = useMemo(() => /^https?:\/\//.test(badgeValue), [badgeValue]);
|
||||
|
||||
if (!badgeKey || !badgeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderValue = (): JSX.Element => {
|
||||
if (typeof badgeValue !== 'string') {
|
||||
return <div className="key-value-label__value">{badgeValue}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={badgeValue}>
|
||||
<div
|
||||
className={`key-value-label__value ${
|
||||
onClick ? 'key-value-label__value--clickable' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`key-value-label key-value-label--${direction}`}>
|
||||
<div className="key-value-label">
|
||||
<div className="key-value-label__key">
|
||||
{typeof badgeKey === 'string' ? (
|
||||
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
|
||||
@@ -63,13 +31,26 @@ export default function KeyValueLabel({
|
||||
badgeKey
|
||||
)}
|
||||
</div>
|
||||
{renderValue()}
|
||||
{isUrl ? (
|
||||
<a
|
||||
href={badgeValue}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="key-value-label__value"
|
||||
>
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</a>
|
||||
) : (
|
||||
<Tooltip title={badgeValue}>
|
||||
<div className="key-value-label__value">
|
||||
<TrimmedText text={badgeValue} maxCharacters={maxCharacters} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
KeyValueLabel.defaultProps = {
|
||||
maxCharacters: 20,
|
||||
direction: 'row',
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
.pretty-view {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
background: var(--l1-background) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
// Override react-json-tree inline styles
|
||||
ul {
|
||||
margin: 0 !important;
|
||||
padding-left: 16px !important;
|
||||
list-style: none !important;
|
||||
background-color: transparent !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> ul,
|
||||
&__pinned > ul {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
// Force font on all tree elements
|
||||
li,
|
||||
span,
|
||||
label {
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
letter-spacing: inherit !important;
|
||||
}
|
||||
|
||||
// Fix react-json-tree text-indent wrapping
|
||||
li {
|
||||
text-indent: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.pretty-view__actions {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Leaf node row — hover highlights only this row
|
||||
&__row {
|
||||
border-radius: 2px;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Push actions to the right edge
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested node (object/array) — hover only on the label line, not children
|
||||
&__nested-row {
|
||||
> label,
|
||||
> span:not(ul span) {
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
display: inline !important; // keep item string inline with label
|
||||
}
|
||||
|
||||
// Highlight label + item string on hover, show actions
|
||||
&:hover > label,
|
||||
&:hover > label + span {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
&:hover > label + span .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// In nested rows, value-row should not take full width
|
||||
.pretty-view__value-row {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Value + actions wrapper (from valueRenderer / getItemString)
|
||||
&__value-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// In leaf rows, value-row takes full width to push ... to the right
|
||||
&__row &__value-row {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ... actions button — hidden by default, shown on row hover
|
||||
&__actions {
|
||||
opacity: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.1s ease;
|
||||
padding: 0 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned items section
|
||||
&__pinned {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 0px;
|
||||
|
||||
li {
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&__pinned-header {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l3-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__pinned-label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__pinned-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-robin-400);
|
||||
cursor: pointer;
|
||||
fill: var(--text-robin-400);
|
||||
transition: fill 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-robin-300);
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { JSONTree, KeyPath } from 'react-json-tree';
|
||||
import { Copy, Ellipsis, Pin, PinOff } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
// TODO: Replace antd Dropdown with @signozhq/ui component when moving to design library
|
||||
import { Dropdown } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { darkTheme, lightTheme, themeExtension } from './constants';
|
||||
import usePinnedFields from './hooks/usePinnedFields';
|
||||
import useSearchFilter, { filterTree } from './hooks/useSearchFilter';
|
||||
import {
|
||||
copyToClipboard,
|
||||
keyPathToDisplayString,
|
||||
keyPathToForward,
|
||||
serializeKeyPath,
|
||||
} from './utils';
|
||||
|
||||
import './PrettyView.styles.scss';
|
||||
|
||||
export interface FieldContext {
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
fieldValue: unknown;
|
||||
isNested: boolean;
|
||||
}
|
||||
|
||||
export interface PrettyViewAction {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick: (context: FieldContext) => void;
|
||||
}
|
||||
|
||||
export interface PrettyViewProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: Record<string, any>;
|
||||
actions?: PrettyViewAction[];
|
||||
searchable?: boolean;
|
||||
showPinned?: boolean;
|
||||
drawerKey?: string;
|
||||
}
|
||||
|
||||
function PrettyView({
|
||||
data,
|
||||
actions,
|
||||
searchable = true,
|
||||
showPinned = false,
|
||||
drawerKey = 'default',
|
||||
}: PrettyViewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { searchQuery, setSearchQuery, filteredData } = useSearchFilter(data);
|
||||
const {
|
||||
isPinned,
|
||||
togglePin,
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
} = usePinnedFields(data, drawerKey);
|
||||
|
||||
const filteredPinnedData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) {
|
||||
return pinnedData;
|
||||
}
|
||||
return filterTree(pinnedData, trimmed) || {};
|
||||
}, [pinnedData, searchQuery]);
|
||||
|
||||
const theme = useMemo(
|
||||
() => ({
|
||||
extend: isDarkMode ? darkTheme : lightTheme,
|
||||
...themeExtension,
|
||||
}),
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
const shouldExpandNodeInitially = useCallback(
|
||||
(
|
||||
_keyPath: readonly (string | number)[],
|
||||
_data: unknown,
|
||||
level: number,
|
||||
): boolean => level < 5,
|
||||
[],
|
||||
);
|
||||
|
||||
const buildMenuItems = useCallback(
|
||||
(context: FieldContext): MenuProps['items'] => {
|
||||
// todo: drive dropdown through config.
|
||||
const copyItem = {
|
||||
key: 'copy',
|
||||
label: 'Copy',
|
||||
icon: <Copy size={12} />,
|
||||
onClick: (): void => {
|
||||
copyToClipboard(context.fieldValue);
|
||||
},
|
||||
};
|
||||
|
||||
const items: NonNullable<MenuProps['items']> = [copyItem];
|
||||
|
||||
// Pin action only for leaf nodes
|
||||
if (!context.isNested) {
|
||||
// Resolve the correct forward path — pinned tree uses display keys
|
||||
// which don't match the original serialized path
|
||||
const resolvedPath =
|
||||
displayKeyToForwardPath[context.fieldKey] || context.fieldKeyPath;
|
||||
const serialized = serializeKeyPath(resolvedPath);
|
||||
const pinned = isPinned(serialized);
|
||||
|
||||
items.push({
|
||||
key: 'pin',
|
||||
label: pinned ? 'Unpin field' : 'Pin field',
|
||||
icon: pinned ? <PinOff size={12} /> : <Pin size={12} />,
|
||||
onClick: (): void => {
|
||||
togglePin(resolvedPath);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
//todo: why this divider?
|
||||
items.push({ type: 'divider' as const, key: 'divider' });
|
||||
actions.forEach((action) => {
|
||||
items.push({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
onClick: (): void => {
|
||||
action.onClick(context);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
[actions, isPinned, togglePin, displayKeyToForwardPath],
|
||||
);
|
||||
|
||||
const renderWithActions = useCallback(
|
||||
({
|
||||
content,
|
||||
fieldKey,
|
||||
fieldKeyPath,
|
||||
value,
|
||||
isNested,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
fieldKey: string;
|
||||
fieldKeyPath: (string | number)[];
|
||||
value: unknown;
|
||||
isNested: boolean;
|
||||
}): React.ReactNode => {
|
||||
const context: FieldContext = {
|
||||
fieldKey,
|
||||
fieldKeyPath,
|
||||
fieldValue: value,
|
||||
isNested,
|
||||
};
|
||||
const menuItems = buildMenuItems(context);
|
||||
return (
|
||||
<span className="pretty-view__value-row">
|
||||
<span>{content}</span>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: (e): void => {
|
||||
e.domEvent.stopPropagation();
|
||||
},
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement || document.body
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="pretty-view__actions"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Ellipsis size={12} />
|
||||
</span>
|
||||
</Dropdown>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[buildMenuItems],
|
||||
);
|
||||
|
||||
const getItemString = useCallback(
|
||||
(
|
||||
_nodeType: string,
|
||||
nodeData: unknown,
|
||||
itemType: React.ReactNode,
|
||||
itemString: string,
|
||||
keyPath: KeyPath,
|
||||
): // eslint-disable-next-line max-params
|
||||
React.ReactNode => {
|
||||
const forwardPath = keyPathToForward(keyPath);
|
||||
return renderWithActions({
|
||||
content: (
|
||||
<>
|
||||
{itemType} {itemString}
|
||||
</>
|
||||
),
|
||||
fieldKey: keyPathToDisplayString(keyPath),
|
||||
fieldKeyPath: forwardPath,
|
||||
value: nodeData,
|
||||
isNested: true,
|
||||
});
|
||||
},
|
||||
[renderWithActions],
|
||||
);
|
||||
|
||||
const valueRenderer = useCallback(
|
||||
(
|
||||
valueAsString: unknown,
|
||||
value: unknown,
|
||||
...keyPath: KeyPath
|
||||
): React.ReactNode => {
|
||||
const forwardPath = keyPathToForward(keyPath);
|
||||
return renderWithActions({
|
||||
content: String(valueAsString),
|
||||
fieldKey: keyPathToDisplayString(keyPath),
|
||||
fieldKeyPath: forwardPath,
|
||||
value,
|
||||
isNested: typeof value === 'object' && value !== null,
|
||||
});
|
||||
},
|
||||
[renderWithActions],
|
||||
);
|
||||
|
||||
const pinnedLabelRenderer = useCallback(
|
||||
(keyPath: KeyPath): React.ReactNode => {
|
||||
const displayKey = String(keyPath[0]);
|
||||
const entry = pinnedEntries.find((e) => e.displayKey === displayKey);
|
||||
return (
|
||||
<span className="pretty-view__pinned-label">
|
||||
<Pin
|
||||
size={12}
|
||||
className="pretty-view__pinned-icon"
|
||||
onClick={(): void => {
|
||||
if (entry) {
|
||||
togglePin(entry.forwardPath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{displayKey}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[togglePin, pinnedEntries],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pretty-view">
|
||||
{searchable && (
|
||||
<Input
|
||||
className="pretty-view__search-input"
|
||||
type="text"
|
||||
placeholder="Search for a field..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPinned && Object.keys(filteredPinnedData).length > 0 && (
|
||||
<div className="pretty-view__pinned">
|
||||
<div className="pretty-view__pinned-header">PINNED ITEMS</div>
|
||||
<JSONTree
|
||||
key={`pinned-${searchQuery}`}
|
||||
data={filteredPinnedData}
|
||||
theme={theme}
|
||||
invertTheme={false}
|
||||
hideRoot
|
||||
shouldExpandNodeInitially={shouldExpandNodeInitially}
|
||||
valueRenderer={valueRenderer}
|
||||
getItemString={getItemString}
|
||||
labelRenderer={pinnedLabelRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JSONTree
|
||||
key={searchQuery}
|
||||
data={filteredData}
|
||||
theme={theme}
|
||||
invertTheme={false}
|
||||
hideRoot
|
||||
shouldExpandNodeInitially={shouldExpandNodeInitially}
|
||||
valueRenderer={valueRenderer}
|
||||
getItemString={getItemString}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrettyView;
|
||||
@@ -1,62 +0,0 @@
|
||||
// Dark theme — SigNoz design palette
|
||||
export const darkTheme = {
|
||||
scheme: 'signoz-dark',
|
||||
author: 'signoz',
|
||||
base00: 'transparent',
|
||||
base01: '#161922',
|
||||
base02: '#1d212d',
|
||||
base03: '#62687C',
|
||||
base04: '#ADB4C2',
|
||||
base05: '#ADB4C2',
|
||||
base06: '#ADB4C2',
|
||||
base07: '#ADB4C2',
|
||||
base08: '#EA6D71',
|
||||
base09: '#7CEDBD',
|
||||
base0A: '#7CEDBD',
|
||||
base0B: '#ADB4C2',
|
||||
base0C: '#23C4F8',
|
||||
base0D: '#95ACFB',
|
||||
base0E: '#95ACFB',
|
||||
base0F: '#AD7F58',
|
||||
};
|
||||
|
||||
// Light theme
|
||||
export const lightTheme = {
|
||||
scheme: 'signoz-light',
|
||||
author: 'signoz',
|
||||
base00: 'transparent',
|
||||
base01: '#F9F9FB',
|
||||
base02: '#EFF0F3',
|
||||
base03: '#80828D',
|
||||
base04: '#62636C',
|
||||
base05: '#62636C',
|
||||
base06: '#62636C',
|
||||
base07: '#1E1F24',
|
||||
base08: '#E5484D',
|
||||
base09: '#168757',
|
||||
base0A: '#168757',
|
||||
base0B: '#62636C',
|
||||
base0C: '#157594',
|
||||
base0D: '#2F48A0',
|
||||
base0E: '#2F48A0',
|
||||
base0F: '#684C35',
|
||||
};
|
||||
|
||||
export const themeExtension = {
|
||||
value: ({
|
||||
style,
|
||||
}: {
|
||||
style: Record<string, unknown>;
|
||||
}): { style: Record<string, unknown>; className: string } => ({
|
||||
style: { ...style },
|
||||
className: 'pretty-view__row',
|
||||
}),
|
||||
nestedNode: ({
|
||||
style,
|
||||
}: {
|
||||
style: Record<string, unknown>;
|
||||
}): { style: Record<string, unknown>; className: string } => ({
|
||||
style: { ...style },
|
||||
className: 'pretty-view__nested-row',
|
||||
}),
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
deserializeKeyPath,
|
||||
keyPathToDisplayString,
|
||||
resolveValueByKeys,
|
||||
serializeKeyPath,
|
||||
} from '../utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
const STORAGE_PREFIX = 'pinnedFields';
|
||||
|
||||
function loadFromStorage(storageKey: string): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(storageKey: string, keys: string[]): void {
|
||||
localStorage.setItem(storageKey, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
export interface PinnedEntry {
|
||||
serializedKey: string;
|
||||
displayKey: string;
|
||||
forwardPath: (string | number)[];
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface UsePinnedFieldsReturn {
|
||||
isPinned: (serializedKey: string) => boolean;
|
||||
togglePin: (forwardPath: (string | number)[]) => void;
|
||||
pinnedEntries: PinnedEntry[];
|
||||
pinnedData: AnyRecord;
|
||||
displayKeyToForwardPath: Record<string, (string | number)[]>;
|
||||
}
|
||||
|
||||
function usePinnedFields(
|
||||
data: AnyRecord,
|
||||
drawerKey: string,
|
||||
): UsePinnedFieldsReturn {
|
||||
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
|
||||
|
||||
// Stored as serialized keyPath arrays (JSON strings)
|
||||
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
|
||||
() => new Set(loadFromStorage(storageKey)),
|
||||
);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(forwardPath: (string | number)[]): void => {
|
||||
const serialized = serializeKeyPath(forwardPath);
|
||||
setPinnedSerializedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serialized)) {
|
||||
next.delete(serialized);
|
||||
} else {
|
||||
next.add(serialized);
|
||||
}
|
||||
saveToStorage(storageKey, Array.from(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(serializedKey: string): boolean => pinnedSerializedKeys.has(serializedKey),
|
||||
[pinnedSerializedKeys],
|
||||
);
|
||||
|
||||
const pinnedEntries = useMemo(
|
||||
(): PinnedEntry[] =>
|
||||
Array.from(pinnedSerializedKeys)
|
||||
.map((serializedKey) => {
|
||||
const forwardPath = deserializeKeyPath(serializedKey);
|
||||
if (!forwardPath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serializedKey,
|
||||
displayKey: keyPathToDisplayString(
|
||||
[...forwardPath].reverse() as readonly (string | number)[],
|
||||
),
|
||||
forwardPath,
|
||||
value: resolveValueByKeys(data, forwardPath),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is PinnedEntry =>
|
||||
entry !== null && entry.value !== undefined,
|
||||
),
|
||||
[pinnedSerializedKeys, data],
|
||||
);
|
||||
|
||||
// Flat object for the pinned JSONTree — use display key as the object key
|
||||
const pinnedData = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
pinnedEntries.map((entry) => [entry.displayKey, entry.value]),
|
||||
),
|
||||
[pinnedEntries],
|
||||
);
|
||||
|
||||
// Map from display key to original forward path — for unpin from pinned tree
|
||||
const displayKeyToForwardPath = useMemo(
|
||||
(): Record<string, (string | number)[]> =>
|
||||
Object.fromEntries(
|
||||
pinnedEntries.map((entry) => [entry.displayKey, entry.forwardPath]),
|
||||
),
|
||||
[pinnedEntries],
|
||||
);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
togglePin,
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePinnedFields;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyRecord = Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyValue = any;
|
||||
|
||||
function isLeaf(value: unknown): boolean {
|
||||
return value === null || value === undefined || typeof value !== 'object';
|
||||
}
|
||||
|
||||
function matchesQuery(text: string, lowerQuery: string): boolean {
|
||||
return text.toLowerCase().includes(lowerQuery);
|
||||
}
|
||||
|
||||
// Filter a single value (leaf, array, or object) against the query.
|
||||
// Returns the filtered value or null if no match.
|
||||
function filterValue(value: AnyValue, query: string): AnyValue | null {
|
||||
if (isLeaf(value)) {
|
||||
return matchesQuery(String(value), query.toLowerCase()) ? value : null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return filterArray(value, query);
|
||||
}
|
||||
|
||||
return filterTree(value, query);
|
||||
}
|
||||
|
||||
// Recursively filter an array, keeping only elements that match
|
||||
function filterArray(arr: AnyValue[], query: string): AnyValue[] | null {
|
||||
const results = arr
|
||||
.map((item) => filterValue(item, query))
|
||||
.filter((item) => item !== null);
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
// Recursively filter the data tree, keeping only branches with matching keys or values
|
||||
export function filterTree(obj: AnyRecord, query: string): AnyRecord | null {
|
||||
const result: AnyRecord = {};
|
||||
const lowerQuery = query.toLowerCase();
|
||||
let hasMatch = false;
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (matchesQuery(key, lowerQuery)) {
|
||||
result[key] = value;
|
||||
hasMatch = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = filterValue(value, query);
|
||||
if (filtered !== null) {
|
||||
result[key] = filtered;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasMatch ? result : null;
|
||||
}
|
||||
|
||||
interface UseSearchFilterReturn {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredData: AnyRecord;
|
||||
}
|
||||
|
||||
function useSearchFilter(data: AnyRecord): UseSearchFilterReturn {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
if (!trimmed) {
|
||||
return data;
|
||||
}
|
||||
return filterTree(data, trimmed) || {};
|
||||
}, [data, searchQuery]);
|
||||
|
||||
return { searchQuery, setSearchQuery, filteredData };
|
||||
}
|
||||
|
||||
export default useSearchFilter;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { PrettyViewProps } from './PrettyView';
|
||||
export { default as PrettyView } from './PrettyView';
|
||||
@@ -1,58 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
|
||||
export function copyToClipboard(value: unknown): void {
|
||||
const text =
|
||||
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a value from a nested object using an array of keys (not dot-notation)
|
||||
// e.g. resolveValueByKeys({ tagMap: { 'cloud.account.id': 'x' } }, ['tagMap', 'cloud.account.id']) → 'x'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function resolveValueByKeys(
|
||||
data: Record<string, any>,
|
||||
keys: (string | number)[],
|
||||
): unknown {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return keys.reduce((obj: any, key) => obj?.[key], data);
|
||||
}
|
||||
|
||||
// Convert react-json-tree's reversed keyPath to forward order
|
||||
// e.g. ['cloud.account.id', 'tagMap'] → ['tagMap', 'cloud.account.id']
|
||||
export function keyPathToForward(
|
||||
keyPath: readonly (string | number)[],
|
||||
): (string | number)[] {
|
||||
return [...keyPath].reverse();
|
||||
}
|
||||
|
||||
// Display-friendly string for a keyPath
|
||||
// e.g. ['tagMap', 'cloud.account.id'] → 'tagMap.cloud.account.id'
|
||||
export function keyPathToDisplayString(
|
||||
keyPath: readonly (string | number)[],
|
||||
): string {
|
||||
return [...keyPath].reverse().join('.');
|
||||
}
|
||||
|
||||
// Serialize keyPath for storage/comparison (JSON stringified array)
|
||||
export function serializeKeyPath(keyPath: (string | number)[]): string {
|
||||
return JSON.stringify(keyPath);
|
||||
}
|
||||
|
||||
export function deserializeKeyPath(
|
||||
serialized: string,
|
||||
): (string | number)[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(serialized);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
.resizable-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--disabled {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: var(--l2-border);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--vertical {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
&--horizontal {
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import './ResizableBox.styles.scss';
|
||||
|
||||
export interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
defaultHeight?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
onResize?: (size: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ResizableBox({
|
||||
children,
|
||||
direction = 'vertical',
|
||||
defaultHeight = 200,
|
||||
minHeight = 50,
|
||||
maxHeight = Infinity,
|
||||
defaultWidth = 200,
|
||||
minWidth = 50,
|
||||
maxWidth = Infinity,
|
||||
onResize,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ResizableBoxProps): JSX.Element {
|
||||
const isHorizontal = direction === 'horizontal';
|
||||
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||||
const startSize = size;
|
||||
const min = isHorizontal ? minWidth : minHeight;
|
||||
const max = isHorizontal ? maxWidth : maxHeight;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = currentPos - startPos;
|
||||
const newSize = Math.min(max, Math.max(min, startSize + delta));
|
||||
setSize(newSize);
|
||||
onResize?.(newSize);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
|
||||
);
|
||||
|
||||
const containerStyle = disabled
|
||||
? undefined
|
||||
: isHorizontal
|
||||
? { width: size }
|
||||
: { height: size };
|
||||
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`resizable-box ${disabled ? 'resizable-box--disabled' : ''} ${
|
||||
className || ''
|
||||
}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="resizable-box__content">{children}</div>
|
||||
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResizableBox;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ResizableBoxProps } from './ResizableBox';
|
||||
export { default as ResizableBox } from './ResizableBox';
|
||||
@@ -6538,11 +6538,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3"
|
||||
integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==
|
||||
|
||||
"@types/lodash@^4.17.0", "@types/lodash@^4.17.15":
|
||||
version "4.17.24"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f"
|
||||
integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==
|
||||
|
||||
"@types/mdast@^3.0.0":
|
||||
version "3.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.12.tgz#beeb511b977c875a5b0cc92eab6fcac2f0895514"
|
||||
@@ -8957,7 +8952,7 @@ color-string@^1.9.0:
|
||||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
||||
color@^4.2.1, color@^4.2.3:
|
||||
color@^4.2.1:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz"
|
||||
integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
|
||||
@@ -9389,11 +9384,6 @@ csstype@^3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
csstype@^3.1.3:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
|
||||
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
|
||||
|
||||
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
|
||||
version "3.2.3"
|
||||
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
|
||||
@@ -16829,11 +16819,6 @@ rc-virtual-list@^3.11.1, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2:
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.36.0"
|
||||
|
||||
re-resizable@^6.11.2:
|
||||
version "6.11.2"
|
||||
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
|
||||
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==
|
||||
|
||||
react-addons-update@15.6.3:
|
||||
version "15.6.3"
|
||||
resolved "https://registry.yarnpkg.com/react-addons-update/-/react-addons-update-15.6.3.tgz#c449c309154024d04087b206d0400e020547b313"
|
||||
@@ -16841,16 +16826,6 @@ react-addons-update@15.6.3:
|
||||
dependencies:
|
||||
object-assign "^4.1.0"
|
||||
|
||||
react-base16-styling@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.10.0.tgz#5d5f019bd4dc5870c3e92fd9d5410533a0bbb0c6"
|
||||
integrity sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==
|
||||
dependencies:
|
||||
"@types/lodash" "^4.17.0"
|
||||
color "^4.2.3"
|
||||
csstype "^3.1.3"
|
||||
lodash-es "^4.17.21"
|
||||
|
||||
react-beautiful-dnd@13.1.1:
|
||||
version "13.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
|
||||
@@ -16915,14 +16890,6 @@ react-draggable@^4.0.0, react-draggable@^4.0.3:
|
||||
clsx "^1.1.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-draggable@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.5.0.tgz#0b274ccb6965fcf97ed38fcf7e3cc223bc48cdf5"
|
||||
integrity sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==
|
||||
dependencies:
|
||||
clsx "^2.1.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
react-error-boundary@4.0.11:
|
||||
version "4.0.11"
|
||||
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.11.tgz#36bf44de7746714725a814630282fee83a7c9a1c"
|
||||
@@ -17013,14 +16980,6 @@ react-is@^18.3.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-json-tree@0.20.0:
|
||||
version "0.20.0"
|
||||
resolved "https://registry.yarnpkg.com/react-json-tree/-/react-json-tree-0.20.0.tgz#1e7fd26f093bd49d733f80dd9b6d40142e09f7c9"
|
||||
integrity sha512-h+f9fUNAxzBx1rbrgUF7+zSWKGHDtt2VPYLErIuB0JyKGnWgFMM21ksqQyb3EXwXNnoMW2rdE5kuAaubgGOx2Q==
|
||||
dependencies:
|
||||
"@types/lodash" "^4.17.15"
|
||||
react-base16-styling "^0.10.0"
|
||||
|
||||
react-kapsule@^2.5:
|
||||
version "2.5.7"
|
||||
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
|
||||
@@ -17140,15 +17099,6 @@ react-resizable@^3.0.4:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-rnd@10.5.3:
|
||||
version "10.5.3"
|
||||
resolved "https://registry.yarnpkg.com/react-rnd/-/react-rnd-10.5.3.tgz#f8c484034276a561e8aa2fbf6a94580596621013"
|
||||
integrity sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==
|
||||
dependencies:
|
||||
re-resizable "^6.11.2"
|
||||
react-draggable "^4.5.0"
|
||||
tslib "2.6.2"
|
||||
|
||||
react-router-dom-v5-compat@6.27.0:
|
||||
version "6.27.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.27.0.tgz#19429aefa130ee151e6f4487d073d919ec22d458"
|
||||
@@ -19234,11 +19184,6 @@ tsconfig-paths@^4.2.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@2.6.2, tslib@^2.0.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tslib@2.7.0:
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
|
||||
@@ -19249,6 +19194,11 @@ tslib@^1.14.1, tslib@^1.8.1:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2.0.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz"
|
||||
|
||||
@@ -63,7 +63,6 @@ type RetryConfig struct {
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
Provider: "noop",
|
||||
BufferSize: 1000,
|
||||
BatchSize: 100,
|
||||
FlushInterval: time.Second,
|
||||
|
||||
32
pkg/modules/cloudintegration/config.go
Normal file
32
pkg/modules/cloudintegration/config.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cloudintegration
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Agent config for cloud integration
|
||||
Agent AgentConfig `mapstructure:"agent"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
Version string `mapstructure:"version"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("cloudintegration"), newConfig)
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.8",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,21 +1,176 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*
|
||||
var definitionFiles embed.FS
|
||||
|
||||
type definitionStore struct{}
|
||||
|
||||
func NewDefinitionStore() citypes.ServiceDefinitionStore {
|
||||
// NewServiceDefinitionStore creates a new ServiceDefinitionStore backed by the embedded filesystem.
|
||||
func NewServiceDefinitionStore() citypes.ServiceDefinitionStore {
|
||||
return &definitionStore{}
|
||||
}
|
||||
|
||||
func (d *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
|
||||
panic("unimplemented")
|
||||
// Get reads and hydrates the service definition for the given provider and service ID.
|
||||
func (s *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
|
||||
svcDir := path.Join(definitionsRoot, provider.StringValue(), serviceID.StringValue())
|
||||
def, err := readServiceDefinition(svcDir)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeNotFound, citypes.ErrCodeServiceDefinitionNotFound, fmt.Sprintf("service definition not found for service id %q", serviceID.StringValue()))
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func (d *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
|
||||
panic("unimplemented")
|
||||
// List reads and hydrates all service definitions for the given provider, sorted by ID.
|
||||
func (s *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
|
||||
providerDir := path.Join(definitionsRoot, provider.StringValue())
|
||||
entries, err := fs.ReadDir(definitionFiles, providerDir)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition dirs for %s", provider.StringValue())
|
||||
}
|
||||
|
||||
var result []*citypes.ServiceDefinition
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
svcDir := path.Join(providerDir, entry.Name())
|
||||
def, err := readServiceDefinition(svcDir)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition for %s/%s", provider.StringValue(), entry.Name())
|
||||
}
|
||||
result = append(result, def)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// following are helper functions for reading and hydrating service definitions,
|
||||
// not keeping this in types as this is an implementation detail of the definition store.
|
||||
func readServiceDefinition(svcDir string) (*citypes.ServiceDefinition, error) {
|
||||
integrationJSONPath := path.Join(svcDir, "integration.json")
|
||||
raw, err := definitionFiles.ReadFile(integrationJSONPath)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", integrationJSONPath)
|
||||
}
|
||||
|
||||
var specMap map[string]any
|
||||
if err := json.Unmarshal(raw, &specMap); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse %s", integrationJSONPath)
|
||||
}
|
||||
|
||||
hydrated, err := hydrateFileURIs(specMap, definitionFiles, svcDir)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't hydrate file URIs in %s", integrationJSONPath)
|
||||
}
|
||||
|
||||
reEncoded, err := json.Marshal(hydrated)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't re-encode hydrated spec from %s", integrationJSONPath)
|
||||
}
|
||||
|
||||
var def citypes.ServiceDefinition
|
||||
decoder := json.NewDecoder(bytes.NewReader(reEncoded))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&def); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't decode service definition from %s", integrationJSONPath)
|
||||
}
|
||||
|
||||
if err := validateServiceDefinition(&def); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "invalid service definition in %s", svcDir)
|
||||
}
|
||||
|
||||
return &def, nil
|
||||
}
|
||||
|
||||
func validateServiceDefinition(def *citypes.ServiceDefinition) error {
|
||||
if def.TelemetryCollectionStrategy == nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "telemetryCollectionStrategy is required")
|
||||
}
|
||||
|
||||
seenDashboardIDs := map[string]struct{}{}
|
||||
for _, d := range def.Assets.Dashboards {
|
||||
if _, seen := seenDashboardIDs[d.ID]; seen {
|
||||
return errors.NewInternalf(errors.CodeInternal, "duplicate dashboard id %q", d.ID)
|
||||
}
|
||||
seenDashboardIDs[d.ID] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hydrateFileURIs walks a JSON-decoded value and replaces any "file://<path>" strings
|
||||
// with the actual file contents (text for .md, base64 data URI for .svg, parsed JSON for .json).
|
||||
func hydrateFileURIs(v any, embeddedFS embed.FS, basedir string) (any, error) {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(val))
|
||||
for k, child := range val {
|
||||
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[k] = hydrated
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case []any:
|
||||
result := make([]any, len(val))
|
||||
for i, child := range val {
|
||||
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = hydrated
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case string:
|
||||
if !strings.HasPrefix(val, "file://") {
|
||||
return val, nil
|
||||
}
|
||||
return readEmbeddedFile(embeddedFS, path.Join(basedir, val[len("file://"):]))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func readEmbeddedFile(embeddedFS embed.FS, filePath string) (any, error) {
|
||||
contents, err := embeddedFS.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read embedded file %s", filePath)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(filePath, ".md"):
|
||||
return string(contents), nil
|
||||
case strings.HasSuffix(filePath, ".svg"):
|
||||
return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(contents)), nil
|
||||
case strings.HasSuffix(filePath, ".json"):
|
||||
var parsed any
|
||||
if err := json.Unmarshal(contents, &parsed); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse JSON file %s", filePath)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "unsupported file type for embedded reference: %s", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,493 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
func NewHandler() cloudintegration.Handler {
|
||||
return &handler{}
|
||||
type handler struct {
|
||||
module cloudintegration.Module
|
||||
}
|
||||
|
||||
func (handler *handler) GetConnectionCredentials(http.ResponseWriter, *http.Request) {
|
||||
panic("unimplemented")
|
||||
func NewHandler(module cloudintegration.Module) cloudintegration.Handler {
|
||||
return &handler{
|
||||
module: module,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) GetConnectionCredentials(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := handler.module.GetConnectionCredentials(ctx, valuer.MustNewUUID(claims.OrgID), provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, creds)
|
||||
}
|
||||
|
||||
func (handler *handler) ListAccounts(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) CreateAccount(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
postableAccount := new(cloudintegrationtypes.PostableAccount)
|
||||
|
||||
err = binding.JSON.BindBody(r.Body, postableAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := postableAccount.Validate(provider); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
accountConfig, err := cloudintegrationtypes.NewAccountConfigFromPostable(provider, postableAccount.Config)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
account := cloudintegrationtypes.NewAccount(valuer.MustNewUUID(claims.OrgID), provider, accountConfig)
|
||||
err = handler.module.CreateAccount(ctx, account)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
connectionArtifact, err := handler.module.GetConnectionArtifact(ctx, account, postableAccount)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &cloudintegrationtypes.GettableAccountWithConnectionArtifact{
|
||||
ID: account.ID,
|
||||
ConnectionArtifact: connectionArtifact,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *handler) GetAccount(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) GetAccount(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
accountIDString := mux.Vars(r)["id"]
|
||||
accountID, err := valuer.NewUUID(accountIDString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := handler.module.GetAccount(ctx, valuer.MustNewUUID(claims.OrgID), accountID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, account)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateAccount(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) ListAccounts(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := handler.module.ListAccounts(ctx, valuer.MustNewUUID(claims.OrgID), provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &cloudintegrationtypes.GettableAccounts{
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *handler) DisconnectAccount(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) UpdateAccount(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
cloudIntegrationID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(cloudintegrationtypes.UpdatableAccount)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(provider); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := handler.module.GetAccount(ctx, valuer.MustNewUUID(claims.OrgID), cloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := account.Update(req.Config); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListServicesMetadata(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) DisconnectAccount(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
cloudIntegrationID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DisconnectAccount(ctx, valuer.MustNewUUID(claims.OrgID), cloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetService(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var integrationID *valuer.UUID
|
||||
if idStr := r.URL.Query().Get("cloud_integration_id"); idStr != "" {
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
integrationID = &id
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
if integrationID != nil {
|
||||
account, err := handler.module.GetAccount(ctx, orgID, *integrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if account.IsRemoved() {
|
||||
render.Error(rw, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration account is removed"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services, err := handler.module.ListServicesMetadata(ctx, orgID, provider, integrationID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &cloudintegrationtypes.GettableServicesMetadata{
|
||||
Services: services,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateService(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDString := mux.Vars(r)["service_id"]
|
||||
serviceID, err := cloudintegrationtypes.NewServiceID(provider, serviceIDString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var integrationID *valuer.UUID
|
||||
if idStr := r.URL.Query().Get("cloud_integration_id"); idStr != "" {
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
integrationID = &id
|
||||
}
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
if integrationID != nil {
|
||||
account, err := handler.module.GetAccount(ctx, valuer.MustNewUUID(claims.OrgID), *integrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if account.IsRemoved() {
|
||||
render.Error(rw, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration account is removed"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), integrationID, serviceID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, svc)
|
||||
}
|
||||
|
||||
func (handler *handler) AgentCheckIn(writer http.ResponseWriter, request *http.Request) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceIDString := mux.Vars(r)["service_id"]
|
||||
serviceID, err := cloudintegrationtypes.NewServiceID(provider, serviceIDString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(cloudintegrationtypes.UpdatableService)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(provider, serviceID); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
account, err := handler.module.GetAccount(ctx, orgID, cloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if account.IsRemoved() {
|
||||
render.Error(rw, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration account is removed"))
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := handler.module.GetService(ctx, orgID, &cloudIntegrationID, serviceID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if svc.CloudIntegrationService == nil {
|
||||
cloudIntegrationService := cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, req.Config)
|
||||
err = handler.module.CreateService(ctx, orgID, cloudIntegrationService, provider)
|
||||
} else {
|
||||
svc.CloudIntegrationService.Update(req.Config)
|
||||
err = handler.module.UpdateService(ctx, orgID, svc.CloudIntegrationService, provider)
|
||||
}
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
providerString := mux.Vars(r)["cloud_provider"]
|
||||
provider, err := cloudintegrationtypes.NewCloudProvider(providerString)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(cloudintegrationtypes.PostableAgentCheckIn)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Map old fields → new fields for backward compatibility with old agents
|
||||
// Old agents send account_id (=> cloudIntegrationId) and cloud_account_id (=> providerAccountId)
|
||||
if req.ID != "" {
|
||||
id, err := valuer.NewUUID(req.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
req.CloudIntegrationID = id
|
||||
req.ProviderAccountID = req.AccountID
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
resp, err := handler.module.AgentCheckIn(ctx, orgID, provider, &req.AgentCheckInRequest)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
|
||||
}
|
||||
|
||||
73
pkg/modules/cloudintegration/implcloudintegration/module.go
Normal file
73
pkg/modules/cloudintegration/implcloudintegration/module.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct{}
|
||||
|
||||
func NewModule() cloudintegration.Module {
|
||||
return &module{}
|
||||
}
|
||||
|
||||
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connection credentials is not supported")
|
||||
}
|
||||
|
||||
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "create account is not supported")
|
||||
}
|
||||
|
||||
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get account is not supported")
|
||||
}
|
||||
|
||||
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list accounts is not supported")
|
||||
}
|
||||
|
||||
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
|
||||
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "update account is not supported")
|
||||
}
|
||||
|
||||
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "disconnect account is not supported")
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "create service is not supported")
|
||||
}
|
||||
|
||||
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Service, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get service is not supported")
|
||||
}
|
||||
|
||||
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID *valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list services metadata is not supported")
|
||||
}
|
||||
|
||||
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "update service is not supported")
|
||||
}
|
||||
|
||||
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connection artifact is not supported")
|
||||
}
|
||||
|
||||
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "agent check-in is not supported")
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get dashboard by ID is not supported")
|
||||
}
|
||||
|
||||
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list dashboards is not supported")
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
@@ -1137,12 +1138,19 @@ func (aH *APIHandler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dashboard := new(dashboardtypes.Dashboard)
|
||||
if aH.CloudIntegrationsController.IsCloudIntegrationDashboardUuid(id) {
|
||||
cloudIntegrationDashboard, apiErr := aH.CloudIntegrationsController.GetDashboardById(ctx, orgID, id)
|
||||
if apiErr != nil {
|
||||
render.Error(rw, errorsV2.Wrapf(apiErr, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to get dashboard"))
|
||||
|
||||
if _, _, _, err := cloudintegrationtypes.ParseCloudIntegrationDashboardID(id); err == nil {
|
||||
cloudIntegrationDashboard, err := aH.Signoz.Modules.CloudIntegration.GetDashboardByID(ctx, orgID, id)
|
||||
if err != nil && !errorsV2.Ast(err, errorsV2.TypeLicenseUnavailable) {
|
||||
render.Error(rw, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to get dashboard"))
|
||||
return
|
||||
}
|
||||
|
||||
if cloudIntegrationDashboard == nil {
|
||||
render.Error(rw, errorsV2.Newf(errorsV2.TypeNotFound, errorsV2.CodeNotFound, "dashboard not found"))
|
||||
return
|
||||
}
|
||||
|
||||
dashboard = cloudIntegrationDashboard
|
||||
} else if aH.IntegrationsController.IsInstalledIntegrationDashboardID(id) {
|
||||
integrationDashboard, apiErr := aH.IntegrationsController.GetInstalledIntegrationDashboardById(ctx, orgID, id)
|
||||
@@ -1207,9 +1215,11 @@ func (aH *APIHandler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
dashboards = append(dashboards, installedIntegrationDashboards...)
|
||||
}
|
||||
|
||||
cloudIntegrationDashboards, apiErr := aH.CloudIntegrationsController.AvailableDashboards(ctx, orgID)
|
||||
if apiErr != nil {
|
||||
aH.logger.ErrorContext(ctx, "failed to get dashboards for cloud integrations", errors.Attr(apiErr))
|
||||
cloudIntegrationDashboards, err := aH.Signoz.Modules.CloudIntegration.ListDashboards(ctx, orgID)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errorsV2.TypeLicenseUnavailable) {
|
||||
aH.logger.ErrorContext(ctx, "failed to get dashboards for cloud integrations", errors.Attr(err))
|
||||
}
|
||||
} else {
|
||||
dashboards = append(dashboards, cloudIntegrationDashboards...)
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -125,8 +125,8 @@ type Config struct {
|
||||
// ServiceAccount config
|
||||
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
|
||||
|
||||
// Auditor config
|
||||
Auditor auditor.Config `mapstructure:"auditor"`
|
||||
// CloudIntegration config
|
||||
CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"`
|
||||
}
|
||||
|
||||
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
|
||||
@@ -157,7 +157,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
|
||||
user.NewConfigFactory(),
|
||||
identn.NewConfigFactory(),
|
||||
serviceaccount.NewConfigFactory(),
|
||||
auditor.NewConfigFactory(),
|
||||
cloudintegration.NewConfigFactory(),
|
||||
}
|
||||
|
||||
conf, err := config.New(ctx, resolverConfig, configFactories)
|
||||
|
||||
@@ -97,7 +97,7 @@ func NewHandlers(
|
||||
QuerierHandler: querierHandler,
|
||||
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount),
|
||||
RegistryHandler: registryHandler,
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(),
|
||||
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
|
||||
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
@@ -30,7 +31,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
@@ -71,6 +71,7 @@ type Modules struct {
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
}
|
||||
|
||||
@@ -93,6 +94,8 @@ func NewModules(
|
||||
dashboard dashboard.Module,
|
||||
userGetter user.Getter,
|
||||
userRoleStore authtypes.UserRoleStore,
|
||||
serviceAccount serviceaccount.Module,
|
||||
cloudIntegrationModule cloudintegration.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
@@ -117,7 +120,8 @@ func NewModules(
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
|
||||
ServiceAccount: serviceAccount,
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
@@ -51,7 +54,9 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore)
|
||||
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule())
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -3,8 +3,6 @@ package signoz
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
@@ -314,12 +312,6 @@ func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[fac
|
||||
)
|
||||
}
|
||||
|
||||
func NewAuditorProviderFactories() factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
noopauditor.NewFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
func NewFlaggerProviderFactories(registry featuretypes.Registry) factory.NamedMap[factory.ProviderFactory[flagger.FlaggerProvider, flagger.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
configflagger.NewFactory(registry),
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
@@ -18,12 +17,17 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
pkgimplcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -43,6 +47,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -76,7 +81,6 @@ type SigNoz struct {
|
||||
QueryParser queryparser.QueryParser
|
||||
Flagger flagger.Flagger
|
||||
Gateway gateway.Gateway
|
||||
Auditor auditor.Auditor
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -96,8 +100,8 @@ func New(
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
|
||||
cloudIntegrationCallback func(cloudintegrationtypes.Store, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
|
||||
) (*SigNoz, error) {
|
||||
// Initialize instrumentation
|
||||
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
|
||||
@@ -374,12 +378,6 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize auditor from the variant-specific provider factories
|
||||
auditor, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Auditor, auditorProviderFactories(licensing), config.Auditor.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize authns
|
||||
store := sqlauthnstore.NewStore(sqlstore)
|
||||
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
|
||||
@@ -426,11 +424,19 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount)
|
||||
|
||||
cloudIntegrationStore := pkgimplcloudintegration.NewStore(sqlstore)
|
||||
cloudIntegrationModule, err := cloudIntegrationCallback(cloudIntegrationStore, global, zeus, gateway, licensing, serviceAccount, config.CloudIntegration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule)
|
||||
|
||||
// Initialize identN resolver
|
||||
identNFactories := NewIdentNProviderFactories(tokenizer, modules.ServiceAccount, orgGetter, userGetter, config.User)
|
||||
identNFactories := NewIdentNProviderFactories(tokenizer, serviceAccount, orgGetter, userGetter, config.User)
|
||||
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -479,7 +485,6 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
|
||||
factory.NewNamedService(factory.MustNewName("authz"), authz),
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -526,6 +531,5 @@ func New(
|
||||
QueryParser: queryParser,
|
||||
Flagger: flagger,
|
||||
Gateway: gateway,
|
||||
Auditor: auditor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -175,26 +175,6 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
|
||||
// func NewAccountFromPostableAccount(provider CloudProviderType, account *PostableAccount) (*Account, error) {
|
||||
// req := &Account{
|
||||
// Credentials: account.Credentials,
|
||||
// }
|
||||
|
||||
// switch provider {
|
||||
// case CloudProviderTypeAWS:
|
||||
// req.Config = &ConnectionArtifactRequestConfig{
|
||||
// Aws: &AWSConnectionArtifactRequest{
|
||||
// DeploymentRegion: artifact.Config.Aws.DeploymentRegion,
|
||||
// Regions: artifact.Config.Aws.Regions,
|
||||
// },
|
||||
// }
|
||||
|
||||
// return req, nil
|
||||
// default:
|
||||
// return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
// }
|
||||
// }
|
||||
|
||||
func NewAgentReport(data map[string]any) *AgentReport {
|
||||
return &AgentReport{
|
||||
TimestampMillis: time.Now().UnixMilli(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package zeustypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -56,3 +58,24 @@ func NewGettableHost(data []byte) *GettableHost {
|
||||
Hosts: hosts,
|
||||
}
|
||||
}
|
||||
|
||||
// GettableDeployment represents the parsed deployment info from zeus.GetDeployment.
|
||||
type GettableDeployment struct {
|
||||
Name string
|
||||
SignozAPIUrl string
|
||||
}
|
||||
|
||||
// NewGettableDeployment parses raw GetDeployment bytes into a GettableDeployment.
|
||||
func NewGettableDeployment(data []byte) (*GettableDeployment, error) {
|
||||
parsed := gjson.ParseBytes(data)
|
||||
name := parsed.Get("name").String()
|
||||
dns := parsed.Get("cluster.region.dns").String()
|
||||
if name == "" || dns == "" {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal,
|
||||
"deployment info response missing name or cluster region dns")
|
||||
}
|
||||
return &GettableDeployment{
|
||||
Name: name,
|
||||
SignozAPIUrl: fmt.Sprintf("https://%s.%s", name, dns),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def create_cloud_integration_account(
|
||||
def deprecated_create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: types.SigNoz,
|
||||
) -> Callable[[str, str], dict]:
|
||||
@@ -78,3 +78,78 @@ def create_cloud_integration_account(
|
||||
logger.info("Cleaned up test account: %s", account_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.info("Post-test disconnect cleanup failed: %s", exc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: types.SigNoz,
|
||||
) -> Callable[[str, str], dict]:
|
||||
created_accounts: list[tuple[str, str]] = []
|
||||
|
||||
def _create(
|
||||
admin_token: str,
|
||||
cloud_provider: str = "aws",
|
||||
deployment_region: str = "us-east-1",
|
||||
regions: list[str] | None = None,
|
||||
) -> dict:
|
||||
if regions is None:
|
||||
regions = ["us-east-1"]
|
||||
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
|
||||
|
||||
request_payload = {
|
||||
"config": {
|
||||
cloud_provider: {
|
||||
"deploymentRegion": deployment_region,
|
||||
"regions": regions,
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
|
||||
"sigNozApiKey": "test-api-key-789",
|
||||
"ingestionUrl": "https://ingest.test.signoz.cloud",
|
||||
"ingestionKey": "test-ingestion-key-123456",
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json=request_payload,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Failed to create test account: {response.status_code}: {response.text}"
|
||||
|
||||
data = response.json()["data"]
|
||||
created_accounts.append((data["id"], cloud_provider))
|
||||
|
||||
return data
|
||||
|
||||
yield _create
|
||||
|
||||
if created_accounts:
|
||||
get_token = request.getfixturevalue("get_token")
|
||||
try:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
for account_id, cloud_provider in created_accounts:
|
||||
delete_endpoint = (
|
||||
f"/api/v1/cloud_integrations/{cloud_provider}/accounts/{account_id}"
|
||||
)
|
||||
r = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(delete_endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code != HTTPStatus.NO_CONTENT:
|
||||
logger.info(
|
||||
"Delete cleanup returned %s for account %s",
|
||||
r.status_code,
|
||||
account_id,
|
||||
)
|
||||
logger.info("Cleaned up test account: %s", account_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.info("Post-test delete cleanup failed: %s", exc)
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"""Fixtures for cloud integration tests."""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
from wiremock.client import (
|
||||
HttpMethods,
|
||||
Mapping,
|
||||
MappingRequest,
|
||||
MappingResponse,
|
||||
WireMockMatchers,
|
||||
)
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
@@ -8,7 +17,7 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def simulate_agent_checkin(
|
||||
def deprecated_simulate_agent_checkin(
|
||||
signoz: types.SigNoz,
|
||||
admin_token: str,
|
||||
cloud_provider: str,
|
||||
@@ -38,3 +47,108 @@ def simulate_agent_checkin(
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def setup_create_account_mocks(
|
||||
signoz: types.SigNoz,
|
||||
make_http_mocks: Callable,
|
||||
) -> None:
|
||||
"""Set up Zeus and Gateway mocks required by the CreateAccount endpoint."""
|
||||
make_http_mocks(
|
||||
signoz.zeus,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.GET,
|
||||
url="/v2/deployments/me",
|
||||
headers={
|
||||
"X-Signoz-Cloud-Api-Key": {
|
||||
WireMockMatchers.EQUAL_TO: "secret-key"
|
||||
}
|
||||
},
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"name": "test-deployment",
|
||||
"cluster": {"region": {"dns": "test.signoz.cloud"}},
|
||||
},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
make_http_mocks(
|
||||
signoz.gateway,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.GET,
|
||||
url="/v1/workspaces/me/keys/search?name=aws-integration&page=1&per_page=10",
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": [],
|
||||
"_pagination": {"page": 1, "per_page": 10, "total": 0},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url="/v1/workspaces/me/keys",
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"name": "aws-integration",
|
||||
"value": "test-ingestion-key-123456",
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def simulate_agent_checkin(
|
||||
signoz: types.SigNoz,
|
||||
admin_token: str,
|
||||
cloud_provider: str,
|
||||
account_id: str,
|
||||
cloud_account_id: str,
|
||||
data: dict | None = None,
|
||||
) -> requests.Response:
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in"
|
||||
|
||||
checkin_payload = {
|
||||
"cloudIntegrationId": account_id,
|
||||
"providerAccountId": cloud_account_id,
|
||||
"data": data or {},
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json=checkin_payload,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"Agent check-in failed: %s, response: %s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -76,6 +76,7 @@ def create_signoz(
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_POLL__INTERVAL": "5s",
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__WAIT": "1s",
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__INTERVAL": "5s",
|
||||
"SIGNOZ_CLOUDINTEGRATION_AGENT_VERSION": "v0.0.8",
|
||||
}
|
||||
| sqlstore.env
|
||||
| clickhouse.env
|
||||
|
||||
@@ -6,7 +6,7 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -150,7 +150,7 @@ def test_duplicate_cloud_account_checkins(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test that two accounts cannot check in with the same cloud_account_id."""
|
||||
|
||||
@@ -159,16 +159,16 @@ def test_duplicate_cloud_account_checkins(
|
||||
same_cloud_account_id = str(uuid.uuid4())
|
||||
|
||||
# Create two separate cloud integration accounts via generate-connection-url
|
||||
account1 = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account1 = deprecated_create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account1_id = account1["account_id"]
|
||||
|
||||
account2 = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account2 = deprecated_create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account2_id = account2["account_id"]
|
||||
|
||||
assert account1_id != account2_id, "Two accounts should have different internal IDs"
|
||||
|
||||
# First check-in succeeds: account1 claims cloud_account_id
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -176,7 +176,7 @@ def test_duplicate_cloud_account_checkins(
|
||||
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
|
||||
#
|
||||
# Second check-in should fail: account2 tries to use the same cloud_account_id
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -45,19 +45,21 @@ def test_list_connected_accounts_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test listing connected accounts after creating one."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -93,13 +95,15 @@ def test_get_account_status(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test getting the status of a specific account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
# Create a test account (no check-in needed for status check)
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
# Get account status
|
||||
@@ -152,19 +156,21 @@ def test_update_account_config(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test updating account configuration."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -220,19 +226,21 @@ def test_disconnect_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test disconnecting an account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -6,7 +6,7 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -50,18 +50,20 @@ def test_list_services_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test listing services for a specific connected account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account and do check-in
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -144,18 +146,20 @@ def test_get_service_details_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test getting service details for a specific connected account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account and do check-in
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -248,18 +252,20 @@ def test_update_service_config(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test updating service configuration for a connected account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account and do check-in
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -363,18 +369,20 @@ def test_update_service_config_invalid_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test updating config for a non-existent service should fail."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account and do check-in
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
@@ -410,18 +418,20 @@ def test_update_service_config_disable_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
deprecated_create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test disabling a service by updating config with enabled=false."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Create a test account and do check-in
|
||||
cloud_provider = "aws"
|
||||
account_data = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account_data = deprecated_create_cloud_integration_account(
|
||||
admin_token, cloud_provider
|
||||
)
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
response = simulate_agent_checkin(
|
||||
response = deprecated_simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
164
tests/integration/src/cloudintegrations/05_credentials.py
Normal file
164
tests/integration/src/cloudintegrations/05_credentials.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
from wiremock.client import (
|
||||
HttpMethods,
|
||||
Mapping,
|
||||
MappingRequest,
|
||||
MappingResponse,
|
||||
)
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrationsutils import setup_create_account_mocks
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
CREDENTIALS_ENDPOINT = f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/credentials"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Apply a license so that subsequent cloud integration calls succeed."""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_get_credentials_success(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Happy path: all four credential fields are returned when Zeus and Gateway respond."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
setup_create_account_mocks(signoz, make_http_mocks)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(CREDENTIALS_ENDPOINT),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
data = response.json()["data"]
|
||||
for field in ("sigNozApiUrl", "sigNozApiKey", "ingestionUrl", "ingestionKey"):
|
||||
assert field in data, f"Response should contain '{field}'"
|
||||
assert isinstance(data[field], str), f"'{field}' should be a string"
|
||||
assert (
|
||||
len(data[field]) > 0
|
||||
), f"'{field}' should be non-empty when mocks are set up"
|
||||
|
||||
|
||||
def test_get_credentials_partial_when_zeus_unavailable(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""When Zeus is unavailable, server still returns 200 with partial credentials.
|
||||
|
||||
The server silently ignores errors from individual credential lookups and returns
|
||||
whatever it could resolve. The frontend is responsible for prompting the user to
|
||||
fill in any empty fields.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Reset Zeus mappings so no prior mock bleeds into this test,
|
||||
# ensuring sigNozApiUrl cannot be resolved and will be returned as empty.
|
||||
requests.post(
|
||||
signoz.zeus.host_configs["8080"].get("/__admin/reset"),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Only set up Gateway mocks — Zeus has no mapping, so sigNozApiUrl will be empty
|
||||
make_http_mocks(
|
||||
signoz.gateway,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.GET,
|
||||
url="/v1/workspaces/me/keys/search?name=aws-integration&page=1&per_page=10",
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": [],
|
||||
"_pagination": {"page": 1, "per_page": 10, "total": 0},
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url="/v1/workspaces/me/keys",
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"name": "aws-integration",
|
||||
"value": "test-ingestion-key-123456",
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
),
|
||||
persistent=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(CREDENTIALS_ENDPOINT),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 even without Zeus, got {response.status_code}: {response.text}"
|
||||
|
||||
data = response.json()["data"]
|
||||
for field in ("sigNozApiUrl", "sigNozApiKey", "ingestionUrl", "ingestionKey"):
|
||||
assert field in data, f"Response should always contain '{field}' key"
|
||||
assert isinstance(data[field], str), f"'{field}' should be a string"
|
||||
|
||||
# sigNozApiUrl comes from Zeus, which is unavailable, so it should be empty
|
||||
assert (
|
||||
data["sigNozApiUrl"] == ""
|
||||
), "sigNozApiUrl should be empty when Zeus is unavailable"
|
||||
|
||||
|
||||
def test_get_credentials_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Unsupported cloud provider returns 400."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
"/api/v1/cloud_integrations/gcp/credentials"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.BAD_REQUEST
|
||||
), f"Expected 400 for unsupported provider, got {response.status_code}"
|
||||
assert "error" in response.json(), "Response should contain 'error' field"
|
||||
92
tests/integration/src/cloudintegrations/06_create_account.py
Normal file
92
tests/integration/src/cloudintegrations/06_create_account.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Apply a license so that subsequent cloud integration calls succeed."""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_create_account(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test creating a new cloud integration account for AWS."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
cloud_provider = "aws"
|
||||
|
||||
data = create_cloud_integration_account(
|
||||
admin_token,
|
||||
cloud_provider,
|
||||
deployment_region="us-east-1",
|
||||
regions=["us-east-1", "us-west-2"],
|
||||
)
|
||||
|
||||
assert "id" in data, "Response data should contain 'id' field"
|
||||
assert len(data["id"]) > 0, "id should be a non-empty UUID string"
|
||||
|
||||
assert (
|
||||
"connectionArtifact" in data
|
||||
), "Response data should contain 'connectionArtifact' field"
|
||||
artifact = data["connectionArtifact"]
|
||||
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
|
||||
assert (
|
||||
"connectionUrl" in artifact["aws"]
|
||||
), "connectionArtifact.aws should contain 'connectionUrl'"
|
||||
|
||||
connection_url = artifact["aws"]["connectionUrl"]
|
||||
assert (
|
||||
"console.aws.amazon.com/cloudformation" in connection_url
|
||||
), "connectionUrl should be an AWS CloudFormation URL"
|
||||
assert (
|
||||
"region=us-east-1" in connection_url
|
||||
), "connectionUrl should contain the deployment region"
|
||||
|
||||
|
||||
def test_create_account_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Test that creating an account with an unsupported cloud provider returns 400."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
cloud_provider = "gcp"
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {
|
||||
"gcp": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}
|
||||
},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test.signoz.cloud",
|
||||
"sigNozApiKey": "test-key",
|
||||
"ingestionUrl": "https://ingest.test.signoz.cloud",
|
||||
"ingestionKey": "test-ingestion-key",
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.BAD_REQUEST
|
||||
), f"Expected 400 for unsupported provider, got {response.status_code}"
|
||||
|
||||
response_data = response.json()
|
||||
assert "error" in response_data, "Response should contain 'error' field"
|
||||
129
tests/integration/src/cloudintegrations/07_agent_check_in.py
Normal file
129
tests/integration/src/cloudintegrations/07_agent_check_in.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Apply a license so that subsequent cloud integration calls succeed."""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_agent_check_in(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test agent check-in with new camelCase fields returns 200 with expected response shape."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
|
||||
)
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(
|
||||
signoz,
|
||||
admin_token,
|
||||
CLOUD_PROVIDER,
|
||||
account_id,
|
||||
provider_account_id,
|
||||
data={"version": "v0.0.8"},
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
data = response.json()["data"]
|
||||
|
||||
# New camelCase fields
|
||||
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
|
||||
assert (
|
||||
data["providerAccountId"] == provider_account_id
|
||||
), "providerAccountId should match"
|
||||
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
|
||||
assert data["removedAt"] is None, "removedAt should be null for a live account"
|
||||
|
||||
# Backward-compat snake_case fields
|
||||
assert data["account_id"] == account_id, "account_id (compat) should match"
|
||||
assert (
|
||||
data["cloud_account_id"] == provider_account_id
|
||||
), "cloud_account_id (compat) should match"
|
||||
assert (
|
||||
"integration_config" in data
|
||||
), "Response should contain 'integration_config' (compat)"
|
||||
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
|
||||
|
||||
# integrationConfig should reflect the configured regions
|
||||
integration_config = data["integrationConfig"]
|
||||
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
|
||||
assert integration_config["aws"]["enabledRegions"] == [
|
||||
"us-east-1"
|
||||
], "enabledRegions should match account config"
|
||||
|
||||
|
||||
def test_agent_check_in_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Test that check-in with an unknown cloudIntegrationId returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4())
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_duplicate_cloud_account_checkins(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test that two different accounts cannot check in with the same providerAccountId."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
|
||||
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
|
||||
|
||||
same_provider_account_id = str(uuid.uuid4())
|
||||
|
||||
# First check-in: account1 claims the provider account ID
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Second check-in: account2 tries to claim the same provider account ID → 409
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.CONFLICT
|
||||
), f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"
|
||||
341
tests/integration/src/cloudintegrations/08_accounts.py
Normal file
341
tests/integration/src/cloudintegrations/08_accounts.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Apply a license so that subsequent cloud integration calls succeed."""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_list_accounts_empty(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""List accounts returns an empty list when no accounts have checked in."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert "accounts" in data, "Response should contain 'accounts' field"
|
||||
assert isinstance(data["accounts"], list), "accounts should be a list"
|
||||
assert (
|
||||
len(data["accounts"]) == 0
|
||||
), "accounts list should be empty when no accounts have checked in"
|
||||
|
||||
|
||||
def test_list_accounts_after_checkin(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""List accounts returns an account after it has checked in."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
|
||||
)
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
checkin = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id
|
||||
)
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
found = next((a for a in data["accounts"] if a["id"] == account_id), None)
|
||||
assert (
|
||||
found is not None
|
||||
), f"Account {account_id} should appear in list after check-in"
|
||||
assert (
|
||||
found["providerAccountId"] == provider_account_id
|
||||
), "providerAccountId should match"
|
||||
assert found["config"]["aws"]["regions"] == [
|
||||
"us-east-1"
|
||||
], "regions should match account config"
|
||||
assert (
|
||||
found["agentReport"] is not None
|
||||
), "agentReport should be present after check-in"
|
||||
assert found["removedAt"] is None, "removedAt should be null for a live account"
|
||||
|
||||
|
||||
def test_get_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Get a specific account by ID returns the account with correct fields."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token, CLOUD_PROVIDER, regions=["us-east-1", "eu-west-1"]
|
||||
)
|
||||
account_id = account["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == account_id, "id should match"
|
||||
assert data["config"]["aws"]["regions"] == [
|
||||
"us-east-1",
|
||||
"eu-west-1",
|
||||
], "regions should match"
|
||||
assert data["removedAt"] is None, "removedAt should be null"
|
||||
|
||||
|
||||
def test_get_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Get a non-existent account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
def test_update_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Update account config and verify the change is persisted via GET."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
|
||||
)
|
||||
account_id = account["id"]
|
||||
updated_regions = ["us-east-1", "us-west-2", "eu-west-1"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": updated_regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204, got {response.status_code}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert get_response.status_code == HTTPStatus.OK
|
||||
assert (
|
||||
get_response.json()["data"]["config"]["aws"]["regions"] == updated_regions
|
||||
), "Regions should reflect the update"
|
||||
|
||||
|
||||
def test_update_account_after_checkin_preserves_connected_status(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Updating config after agent check-in must not remove the account from the connected list.
|
||||
|
||||
Regression test: previously, updating an account would reset account_id to NULL,
|
||||
causing the account to disappear from the connected accounts listing
|
||||
(which filters on account_id IS NOT NULL).
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# 1. Create account
|
||||
account = create_cloud_integration_account(
|
||||
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
|
||||
)
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
# 2. Agent checks in — sets account_id and last_agent_report
|
||||
checkin = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id
|
||||
)
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
# 3. Verify the account appears in the connected list
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert list_response.status_code == HTTPStatus.OK
|
||||
accounts_before = list_response.json()["data"]["accounts"]
|
||||
found_before = next((a for a in accounts_before if a["id"] == account_id), None)
|
||||
assert found_before is not None, "Account should be listed after check-in"
|
||||
|
||||
# 4. Update account config
|
||||
updated_regions = ["us-east-1", "us-west-2"]
|
||||
update_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": updated_regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
update_response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204, got {update_response.status_code}"
|
||||
|
||||
# 5. Verify the account still appears in the connected list with correct fields
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert list_response.status_code == HTTPStatus.OK
|
||||
accounts_after = list_response.json()["data"]["accounts"]
|
||||
found_after = next((a for a in accounts_after if a["id"] == account_id), None)
|
||||
assert (
|
||||
found_after is not None
|
||||
), "Account must still be listed after config update (account_id should not be reset)"
|
||||
assert (
|
||||
found_after["providerAccountId"] == provider_account_id
|
||||
), "providerAccountId should be preserved after update"
|
||||
assert (
|
||||
found_after["agentReport"] is not None
|
||||
), "agentReport should be preserved after update"
|
||||
assert (
|
||||
found_after["config"]["aws"]["regions"] == updated_regions
|
||||
), "Config should reflect the update"
|
||||
assert found_after["removedAt"] is None, "removedAt should still be null"
|
||||
|
||||
|
||||
def test_disconnect_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Disconnect an account removes it from the connected list."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(
|
||||
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
|
||||
)
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204, got {response.status_code}"
|
||||
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
accounts = list_response.json()["data"]["accounts"]
|
||||
assert not any(
|
||||
a["id"] == account_id for a in accounts
|
||||
), "Disconnected account should not appear in the connected list"
|
||||
|
||||
|
||||
def test_disconnect_account_idempotent(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Disconnect on a non-existent account ID returns 204 (blind update, no existence check)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204, got {response.status_code}"
|
||||
445
tests/integration/src/cloudintegrations/09_services.py
Normal file
445
tests/integration/src/cloudintegrations/09_services.py
Normal file
@@ -0,0 +1,445 @@
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
SERVICE_ID = "rds"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Apply a license so that subsequent cloud integration calls succeed."""
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
def test_list_services_without_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""List available services without specifying a cloud_integration_id."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert "services" in data, "Response should contain 'services' field"
|
||||
assert isinstance(data["services"], list), "services should be a list"
|
||||
assert len(data["services"]) > 0, "services list should be non-empty"
|
||||
|
||||
service = data["services"][0]
|
||||
assert "id" in service, "Service should have 'id' field"
|
||||
assert "title" in service, "Service should have 'title' field"
|
||||
assert "icon" in service, "Service should have 'icon' field"
|
||||
assert "enabled" in service, "Service should have 'enabled' field"
|
||||
|
||||
|
||||
def test_list_services_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""List services filtered to a specific account — all disabled by default."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert "services" in data, "Response should contain 'services' field"
|
||||
assert len(data["services"]) > 0, "services list should be non-empty"
|
||||
|
||||
for svc in data["services"]:
|
||||
assert "enabled" in svc, "Each service should have 'enabled' field"
|
||||
assert (
|
||||
svc["enabled"] is False
|
||||
), f"Service {svc['id']} should be disabled before any config is set"
|
||||
|
||||
|
||||
def test_get_service_details_without_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Get full service definition without specifying an account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
|
||||
assert "title" in data, "Service should have 'title'"
|
||||
assert "overview" in data, "Service should have 'overview' (markdown)"
|
||||
assert "assets" in data, "Service should have 'assets'"
|
||||
assert isinstance(
|
||||
data["assets"]["dashboards"], list
|
||||
), "assets.dashboards should be a list"
|
||||
assert (
|
||||
"telemetryCollectionStrategy" in data
|
||||
), "Service should have 'telemetryCollectionStrategy'"
|
||||
assert (
|
||||
data["cloudIntegrationService"] is None
|
||||
), "cloudIntegrationService should be null without account context"
|
||||
|
||||
|
||||
def test_get_service_details_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Get service details with account context — cloudIntegrationService is null before first UpdateService."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
|
||||
f"?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == SERVICE_ID
|
||||
assert (
|
||||
data["cloudIntegrationService"] is None
|
||||
), "cloudIntegrationService should be null before any service config is set"
|
||||
|
||||
|
||||
def test_get_service_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.BAD_REQUEST
|
||||
), f"Expected 400, got {response.status_code}"
|
||||
|
||||
|
||||
def test_update_service_config(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Enable a service and verify the config is persisted via GET."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
put_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
put_response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204, got {put_response.status_code}: {put_response.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
|
||||
f"?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert get_response.status_code == HTTPStatus.OK
|
||||
data = get_response.json()["data"]
|
||||
svc = data["cloudIntegrationService"]
|
||||
assert (
|
||||
svc is not None
|
||||
), "cloudIntegrationService should be non-null after UpdateService"
|
||||
assert (
|
||||
svc["config"]["aws"]["metrics"]["enabled"] is True
|
||||
), "metrics should be enabled"
|
||||
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
|
||||
assert (
|
||||
svc["cloudIntegrationId"] == account_id
|
||||
), "cloudIntegrationId should match the account"
|
||||
|
||||
|
||||
def test_update_service_config_disable(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Enable then disable a service — config change is persisted."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
endpoint = signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
|
||||
)
|
||||
|
||||
# Enable
|
||||
r = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
r.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Enable failed: {r.status_code}: {r.text}"
|
||||
|
||||
# Disable
|
||||
r = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {
|
||||
"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}
|
||||
}
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
r.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Disable failed: {r.status_code}: {r.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
|
||||
f"?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert get_response.status_code == HTTPStatus.OK
|
||||
svc = get_response.json()["data"]["cloudIntegrationService"]
|
||||
assert (
|
||||
svc is not None
|
||||
), "cloudIntegrationService should still be present after disable"
|
||||
assert (
|
||||
svc["config"]["aws"]["metrics"]["enabled"] is False
|
||||
), "metrics should be disabled"
|
||||
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
|
||||
|
||||
|
||||
def test_update_service_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""PUT with a non-existent account UUID returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}}}},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
def test_list_services_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""List services for an unsupported cloud provider returns 400."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.BAD_REQUEST
|
||||
), f"Expected 400, got {response.status_code}"
|
||||
|
||||
|
||||
def test_list_services_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""List services with a cloud_integration_id for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
delete_response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
def test_get_service_details_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Get service details with a cloud_integration_id for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
delete_response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
|
||||
f"?cloud_integration_id={account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
def test_update_service_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""PUT service config for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert (
|
||||
delete_response.status_code == HTTPStatus.NO_CONTENT
|
||||
), f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}}}},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.NOT_FOUND
|
||||
), f"Expected 404, got {response.status_code}"
|
||||
Reference in New Issue
Block a user