Compare commits

..

90 Commits

Author SHA1 Message Date
swapnil-signoz
8158a85e86 chore: adding TODO comments 2026-04-01 23:39:00 +05:30
swapnil-signoz
b7d7a5422e Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 22:07:00 +05:30
swapnil-signoz
74b7f4b4e8 feat: adding service definition store 2026-04-01 20:55:55 +05:30
swapnil-signoz
985d66539a Merge branch 'refactor/remove-overview-images' into refactor/cloud-integration-modules 2026-04-01 20:43:31 +05:30
swapnil-signoz
b15f817ba3 refactor: removing dashboard overview images 2026-04-01 20:38:45 +05:30
swapnil-signoz
5e1cf14de9 Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 19:59:21 +05:30
swapnil-signoz
99944b5f92 refactor: renaming tests and cleanup 2026-04-01 15:49:57 +05:30
swapnil-signoz
f8eda16533 feat: using service account for API key 2026-04-01 15:27:24 +05:30
swapnil-signoz
a2eb8ab00a Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 13:16:54 +05:30
swapnil-signoz
601007cba1 chore: lint changes 2026-04-01 13:07:58 +05:30
swapnil-signoz
925a29d2df refactor: reverting older tests and adding new tests 2026-04-01 13:03:56 +05:30
swapnil-signoz
d54fc50236 Merge branch 'main' into refactor/cloud-integration-modules 2026-04-01 11:55:09 +05:30
swapnil-signoz
a2ad5b1172 refactor: adding validation on update account request 2026-03-30 21:37:03 +05:30
swapnil-signoz
802a11ee2b Merge branch 'main' into refactor/cloud-integration-modules 2026-03-30 18:45:55 +05:30
swapnil-signoz
a8124f6e73 refactor: python lint changes 2026-03-30 18:41:35 +05:30
swapnil-signoz
8811aaefe8 fix: new storable account func was unsetting provider account id 2026-03-30 18:28:15 +05:30
swapnil-signoz
66aaaea918 refactor: python formatting change 2026-03-30 12:30:59 +05:30
swapnil-signoz
900c489d91 refactor: ci lint changes 2026-03-30 12:06:03 +05:30
swapnil-signoz
743fe56523 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-29 19:50:35 +05:30
swapnil-signoz
3a9e93ebdf feat: adding module implementation for AWS 2026-03-29 19:49:58 +05:30
swapnil-signoz
cdbb78a93d refactor: simplify ingestion key retrieval logic 2026-03-27 12:03:23 +05:30
swapnil-signoz
c11186f7bf fix: module test 2026-03-27 11:57:40 +05:30
swapnil-signoz
51dbb0b5b9 fix: returning valid error instead of panic 2026-03-27 11:32:25 +05:30
swapnil-signoz
2545d7df61 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-26 01:25:53 +05:30
swapnil-signoz
3f91821825 feat: adding module implementation for create account 2026-03-26 01:22:09 +05:30
swapnil-signoz
ee5d182539 Merge branch 'main' into refactor/cloud-integration-modules 2026-03-24 17:50:54 +05:30
swapnil-signoz
0bc12f02bc Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-24 10:59:04 +05:30
swapnil-signoz
e5f00421fe Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-23 21:05:26 +05:30
swapnil-signoz
539252e10c feat: adding frontend openapi schema 2026-03-23 12:33:14 +05:30
swapnil-signoz
d65f426254 chore: removing todo comment 2026-03-23 12:24:04 +05:30
swapnil-signoz
6e52f2c8f0 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-22 17:13:53 +05:30
swapnil-signoz
d9f8a4ae5a Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-22 17:13:40 +05:30
swapnil-signoz
eefe3edffd Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-22 17:13:02 +05:30
swapnil-signoz
2051861a03 feat: adding handler skeleton 2026-03-22 17:12:35 +05:30
swapnil-signoz
4b01a40fb9 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-20 20:53:54 +05:30
swapnil-signoz
2d8a00bf18 fix: update error code for service not found 2026-03-20 20:53:33 +05:30
swapnil-signoz
f1b26b310f Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-20 20:51:44 +05:30
swapnil-signoz
2c438b6c32 Merge branch 'refactor/cloud-integration-impl-store' into refactor/cloud-integration-handlers 2026-03-20 20:48:34 +05:30
swapnil-signoz
1814c2d13c Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-20 17:52:31 +05:30
swapnil-signoz
e6cd771f11 Merge origin/main into refactor/cloud-integration-handlers 2026-03-20 16:46:36 +05:30
swapnil-signoz
6b94f87ca0 Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-19 11:43:21 +05:30
swapnil-signoz
bf315253ae fix: lint issues 2026-03-19 11:43:09 +05:30
swapnil-signoz
668ff7bc39 fix: lint and ci issues 2026-03-19 11:34:27 +05:30
swapnil-signoz
07f2aa52fd feat: adding handlers 2026-03-19 01:35:01 +05:30
swapnil-signoz
3416b3ad55 Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-18 21:50:40 +05:30
swapnil-signoz
d6caa4f2c7 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-18 14:08:14 +05:30
swapnil-signoz
f86371566d refactor: clean up 2026-03-18 13:45:31 +05:30
swapnil-signoz
9115803084 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-18 13:42:43 +05:30
swapnil-signoz
0c14d8f966 refactor: review comments 2026-03-18 13:40:17 +05:30
swapnil-signoz
7afb461af8 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-18 11:14:33 +05:30
swapnil-signoz
a21fbb4ee0 refactor: clean up 2026-03-18 11:14:05 +05:30
swapnil-signoz
0369842f3d refactor: clean up 2026-03-17 23:40:14 +05:30
swapnil-signoz
59cd96562a Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 23:10:54 +05:30
swapnil-signoz
cc4475cab7 refactor: updating store methods 2026-03-17 23:10:15 +05:30
swapnil-signoz
ac8c648420 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 21:09:47 +05:30
swapnil-signoz
bede6be4b8 feat: adding method for service id creation 2026-03-17 21:09:26 +05:30
swapnil-signoz
dd3d60e6df Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 20:49:31 +05:30
swapnil-signoz
538ab686d2 refactor: using serviceID type 2026-03-17 20:49:17 +05:30
swapnil-signoz
936a325cb9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 17:25:58 +05:30
swapnil-signoz
c6cdcd0143 refactor: renaming service type to service id 2026-03-17 17:25:29 +05:30
swapnil-signoz
cd9211d718 refactor: clean up types 2026-03-17 17:04:27 +05:30
swapnil-signoz
0601c28782 feat: adding integration test 2026-03-17 11:02:46 +05:30
swapnil-signoz
580610dbfa Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-16 23:02:19 +05:30
swapnil-signoz
2d2aa02a81 refactor: split upsert store method 2026-03-16 18:27:42 +05:30
swapnil-signoz
dd9723ad13 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:42:03 +05:30
swapnil-signoz
3651469416 Merge branch 'main' of https://github.com/SigNoz/signoz into refactor/cloud-integration-types 2026-03-16 17:41:52 +05:30
swapnil-signoz
febce75734 refactor: update Dashboard struct comments and remove unused fields 2026-03-16 17:41:28 +05:30
swapnil-signoz
e1616f3487 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:36:15 +05:30
swapnil-signoz
4b94287ac7 refactor: add comments for backward compatibility in PostableAgentCheckInRequest 2026-03-16 15:48:20 +05:30
swapnil-signoz
1575c7c54c refactor: streamlining types 2026-03-16 15:39:32 +05:30
swapnil-signoz
8def3f835b refactor: adding comments and removed wrong code 2026-03-16 11:10:53 +05:30
swapnil-signoz
11ed15f4c5 feat: implement cloud integration store 2026-03-14 17:05:02 +05:30
swapnil-signoz
f47877cca9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 17:01:51 +05:30
swapnil-signoz
bb2b9215ba fix: correct GetService signature and remove shadowed Data field 2026-03-14 16:59:07 +05:30
swapnil-signoz
3111904223 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 16:36:35 +05:30
swapnil-signoz
003e2c30d8 Merge branch 'main' into refactor/cloud-integration-types 2026-03-14 16:25:35 +05:30
swapnil-signoz
00fe516d10 refactor: update cloud integration types and module interface 2026-03-14 16:25:16 +05:30
swapnil-signoz
0305f4f7db refactor: using struct for map 2026-03-13 16:09:26 +05:30
swapnil-signoz
c60019a6dc Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 23:41:22 +05:30
swapnil-signoz
acde2a37fa feat: adding updated types for cloud integration 2026-03-12 23:40:44 +05:30
swapnil-signoz
945241a52a Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 19:45:50 +05:30
swapnil-signoz
e967f80c86 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 16:39:42 +05:30
swapnil-signoz
a09dc325de Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 16:39:20 +05:30
swapnil-signoz
379b4f7fc4 refactor: removing interface check 2026-03-02 14:50:37 +05:30
swapnil-signoz
5e536ae077 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-02 14:49:35 +05:30
swapnil-signoz
234585e642 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 14:49:19 +05:30
swapnil-signoz
2cc14f1ad4 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 14:49:00 +05:30
swapnil-signoz
dc4ed4d239 feat: adding sql store implementation 2026-03-02 14:44:56 +05:30
swapnil-signoz
7281c36873 refactor: store interfaces to use local types and error 2026-03-02 13:27:46 +05:30
swapnil-signoz
40288776e8 feat: adding cloud integration type for refactor 2026-02-28 16:59:14 +05:30
64 changed files with 4362 additions and 1828 deletions

View File

@@ -19,9 +19,12 @@ import (
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"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"
@@ -29,6 +32,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"
@@ -96,6 +100,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},
func(_ cloudintegrationtypes.Store, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))

View File

@@ -16,6 +16,7 @@ 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/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
@@ -30,9 +31,11 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"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"
@@ -40,6 +43,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"
)
@@ -125,7 +129,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)
@@ -137,8 +140,10 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)
},
func(store cloudintegrationtypes.Store, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(store, config.Global, zeus, gateway, licensing, serviceAccount)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
return err

View File

@@ -421,11 +421,11 @@ components:
type: object
CloudintegrationtypesAWSCollectionStrategy:
properties:
aws_logs:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsStrategy'
aws_metrics:
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsStrategy'
s3_buckets:
s3Buckets:
additionalProperties:
items:
type: string
@@ -465,12 +465,12 @@ components:
type: object
CloudintegrationtypesAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
cloudwatchLogsSubscriptions:
items:
properties:
filter_pattern:
filterPattern:
type: string
log_group_name_prefix:
logGroupNamePrefix:
type: string
type: object
nullable: true
@@ -478,7 +478,7 @@ components:
type: object
CloudintegrationtypesAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
cloudwatchMetricStreamFilters:
items:
properties:
MetricNames:
@@ -577,6 +577,26 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesCloudIntegrationService:
nullable: true
properties:
cloudIntegrationId:
type: string
config:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
createdAt:
format: date-time
type: string
id:
type: string
type:
$ref: '#/components/schemas/CloudintegrationtypesServiceID'
updatedAt:
format: date-time
type: string
required:
- id
type: object
CloudintegrationtypesCollectedLogAttribute:
properties:
name:
@@ -710,11 +730,54 @@ components:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesOldAWSCollectionStrategy'
required:
- enabled_regions
- telemetry
type: object
CloudintegrationtypesOldAWSCollectionStrategy:
properties:
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSMetricsStrategy'
provider:
type: string
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesOldAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
items:
properties:
filter_pattern:
type: string
log_group_name_prefix:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesOldAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
items:
properties:
MetricNames:
items:
type: string
type: array
Namespace:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesPostableAgentCheckInRequest:
properties:
account_id:
@@ -743,6 +806,8 @@ components:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
icon:
@@ -751,9 +816,7 @@ components:
type: string
overview:
type: string
serviceConfig:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
supported_signals:
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesCollectionStrategy'
@@ -765,9 +828,10 @@ components:
- icon
- overview
- assets
- supported_signals
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceConfig:
properties:
@@ -776,6 +840,22 @@ components:
required:
- aws
type: object
CloudintegrationtypesServiceID:
enum:
- alb
- api-gateway
- dynamodb
- ec2
- ecs
- eks
- elasticache
- lambda
- msk
- rds
- s3sync
- sns
- sqs
type: string
CloudintegrationtypesServiceMetadata:
properties:
enabled:
@@ -3410,6 +3490,61 @@ paths:
summary: Update account
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in:
post:
deprecated: false
@@ -3577,55 +3712,6 @@ paths:
summary: Get service
tags:
- cloudintegration
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/complete/google:
get:
deprecated: false

View File

@@ -0,0 +1,184 @@
package implcloudprovider
import (
"context"
"encoding/json"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/errors"
"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, creds *cloudintegrationtypes.SignozCredentials, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// TODO: get this from config
agentVersion := "v0.0.8"
baseURL := fmt.Sprintf("https://%s.console.aws.amazon.com/cloudformation/home", req.Aws.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Aws.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", "signoz-integration")
q.Set("templateURL", fmt.Sprintf("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json", agentVersion))
q.Set("param_SigNozIntegrationAgentVersion", agentVersion)
q.Set("param_SigNozApiUrl", creds.SigNozAPIURL)
q.Set("param_SigNozApiKey", creds.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", creds.IngestionURL)
q.Set("param_IngestionKey", creds.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) {
return provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAWS, serviceID)
}
func (provider *awscloudprovider) StorableConfigFromServiceConfig(ctx context.Context, cfg *cloudintegrationtypes.ServiceConfig, supported cloudintegrationtypes.SupportedSignals) (string, error) {
if cfg == nil || cfg.AWS == nil {
return "", nil
}
// Strip signal configs the service does not support before storing.
if !supported.Logs {
cfg.AWS.Logs = nil
}
if !supported.Metrics {
cfg.AWS.Metrics = nil
}
b, err := json.Marshal(cfg.AWS)
if err != nil {
return "", err
}
return string(b), nil
}
func (provider *awscloudprovider) ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*cloudintegrationtypes.ServiceConfig, error) {
if config == "" {
return nil, errors.NewInternalf(errors.CodeInternal, "service config is empty")
}
var awsCfg cloudintegrationtypes.AWSServiceConfig
if err := json.Unmarshal([]byte(config), &awsCfg); err != nil {
return nil, err
}
return &cloudintegrationtypes.ServiceConfig{AWS: &awsCfg}, nil
}
func (provider *awscloudprovider) IsServiceEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
if config == nil || config.AWS == nil {
return false
}
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
}
func (provider *awscloudprovider) IsMetricsEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
if config == nil || config.AWS == nil {
return false
}
return awsMetricsEnabled(config.AWS)
}
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 := &cloudintegrationtypes.AWSMetricsStrategy{}
compiledLogs := &cloudintegrationtypes.AWSLogsStrategy{}
var compiledS3Buckets map[string][]string
for _, storedSvc := range services {
svcCfg, err := provider.ServiceConfigFromStorableServiceConfig(ctx, storedSvc.Config)
if err != nil || svcCfg == nil || svcCfg.AWS == nil {
continue
}
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil || svcDef == nil || svcDef.Strategy == nil || svcDef.Strategy.AWS == nil {
continue
}
strategy := svcDef.Strategy.AWS
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
if awsLogsEnabled(svcCfg.AWS) && svcCfg.AWS.Logs.S3Buckets != nil {
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
}
continue
}
if awsLogsEnabled(svcCfg.AWS) && strategy.Logs != nil {
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
}
if awsMetricsEnabled(svcCfg.AWS) && strategy.Metrics != nil {
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
}
}
awsTelemetry := &cloudintegrationtypes.AWSCollectionStrategy{}
if len(compiledMetrics.StreamFilters) > 0 {
awsTelemetry.Metrics = compiledMetrics
}
if len(compiledLogs.Subscriptions) > 0 {
awsTelemetry.Logs = compiledLogs
}
if compiledS3Buckets != nil {
awsTelemetry.S3Buckets = compiledS3Buckets
}
enabledRegions := []string{}
if account.Config != nil && account.Config.AWS != nil && account.Config.AWS.Regions != nil {
enabledRegions = account.Config.AWS.Regions
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: enabledRegions,
Telemetry: awsTelemetry,
},
}, nil
}
// awsLogsEnabled returns true if the AWS service config has logs explicitly enabled.
func awsLogsEnabled(cfg *cloudintegrationtypes.AWSServiceConfig) bool {
return cfg.Logs != nil && cfg.Logs.Enabled
}
// awsMetricsEnabled returns true if the AWS service config has metrics explicitly enabled.
func awsMetricsEnabled(cfg *cloudintegrationtypes.AWSServiceConfig) bool {
return cfg.Metrics != nil && cfg.Metrics.Enabled
}

View File

@@ -0,0 +1,50 @@
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, creds *cloudintegrationtypes.SignozCredentials, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.ConnectionArtifactRequest) (*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) StorableConfigFromServiceConfig(ctx context.Context, cfg *cloudintegrationtypes.ServiceConfig, supported cloudintegrationtypes.SupportedSignals) (string, error) {
panic("implement me")
}
func (provider *azurecloudprovider) ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*cloudintegrationtypes.ServiceConfig, error) {
panic("implement me")
}
func (provider *azurecloudprovider) IsServiceEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
panic("implement me")
}
func (provider *azurecloudprovider) IsMetricsEnabled(ctx context.Context, config *cloudintegrationtypes.ServiceConfig) bool {
panic("implement me")
}
func (provider *azurecloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
panic("implement me")
}

View File

@@ -0,0 +1,533 @@
package implcloudintegration
import (
"context"
"fmt"
"sort"
"time"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"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"
pkgimpl "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"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
globalConfig global.Config
serviceAccount serviceaccount.Module
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
}
func NewModule(
store cloudintegrationtypes.Store,
globalConfig global.Config,
zeus zeus.Zeus,
gateway gateway.Gateway,
licensing licensing.Licensing,
serviceAccount serviceaccount.Module,
) (cloudintegration.Module, error) {
defStore := pkgimpl.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 &module{
store: store,
globalConfig: globalConfig,
zeus: zeus,
gateway: gateway,
licensing: licensing,
serviceAccount: serviceAccount,
cloudProvidersMap: cloudProvidersMap,
}, 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.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// TODO: evaluate if this check is really required and remove if the deployment promises to always have this configured.
if module.globalConfig.IngestionURL == nil {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "ingestion URL is not configured")
}
// get license to get the deployment details
license, err := module.licensing.GetActive(ctx, account.OrgID)
if err != nil {
return nil, err
}
// get deployment details from zeus
respBytes, err := module.zeus.GetDeployment(ctx, license.Key)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't get deployment")
}
// parse deployment details
deployment, err := zeustypes.NewGettableDeployment(respBytes)
if err != nil {
return nil, err
}
apiKey, err := module.getOrCreateAPIKey(ctx, account.OrgID, account.Provider)
if err != nil {
return nil, err
}
ingestionKey, err := module.getOrCreateIngestionKey(ctx, account.OrgID, account.Provider)
if err != nil {
return nil, err
}
creds := &cloudintegrationtypes.SignozCredentials{
SigNozAPIURL: deployment.SignozAPIUrl,
SigNozAPIKey: apiKey,
IngestionURL: module.globalConfig.IngestionURL.String(),
IngestionKey: ingestionKey,
}
cloudProviderModule, err := module.GetCloudProvider(account.Provider)
if err != nil {
return nil, err
}
return cloudProviderModule.GetConnectionArtifact(ctx, creds, 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
}
account.AccountID = &req.ProviderAccountID
account.LastAgentReport = &cloudintegrationtypes.StorableAgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: 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: &cloudintegrationtypes.ProviderIntegrationConfig{},
RemovedAt: account.RemovedAt,
}, nil
}
// Get account as domain object for config access (enabled regions, etc.)
accountDomain, 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, accountDomain, 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 {
_, err := module.store.GetAccountByID(ctx, orgID, *integrationID, provider)
if err != nil {
return nil, err
}
storedServices, err := module.store.ListServices(ctx, *integrationID)
if err != nil {
return nil, err
}
for _, svc := range storedServices {
serviceConfig, err := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, svc.Config)
if err != nil {
return nil, err
}
if cloudProvider.IsServiceEnabled(ctx, serviceConfig) {
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 {
_, err := module.store.GetAccountByID(ctx, orgID, *integrationID, provider)
if err != nil {
return nil, err
}
storedService, err := module.store.GetServiceByServiceID(ctx, *integrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, 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 := cloudProvider.StorableConfigFromServiceConfig(ctx, service.Config, serviceDefinition.SupportedSignals)
if err != nil {
return err
}
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, 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 := cloudProvider.StorableConfigFromServiceConfig(ctx, integrationService.Config, serviceDefinition.SupportedSignals)
if err != nil {
return err
}
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, configJSON)
return module.store.UpdateService(ctx, storableService)
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
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 := cloudProvider.ServiceConfigFromStorableServiceConfig(ctx, storedSvc.Config)
if err != nil || !cloudProvider.IsMetricsEnabled(ctx, serviceConfig) {
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
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
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")
}
// TODO: use the function in dashboard APIs during removal of older cloud integration code.
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
}

View File

@@ -1,8 +1,9 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
import getAll from 'api/v1/user/get';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -11,9 +12,12 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -59,10 +63,18 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
SuccessResponseV2<UserResponse[]> | undefined,
APIError
>({
queryFn: () => {
if (orgData && orgData.id !== undefined) {
return getAll();
}
return undefined;
},
queryKey: ['getOrgUser'],
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
});
const checkFirstTimeUser = useCallback((): boolean => {

View File

@@ -67,12 +67,9 @@ jest.mock('hooks/useGetTenantLicense', () => ({
// Mock react-query for users fetch
let mockUsersData: { email: string }[] = [];
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useListUsers: jest.fn(() => ({
data: { data: mockUsersData },
isFetching: false,
})),
jest.mock('api/v1/user/get', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: mockUsersData })),
}));
const queryClient = new QueryClient({

View File

@@ -628,6 +628,103 @@ export const useUpdateAccount = <
return useMutation(mutationOptions);
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
@@ -941,101 +1038,3 @@ export const invalidateGetService = async (
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -550,12 +550,12 @@ export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
};
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
logs?: CloudintegrationtypesAWSLogsStrategyDTO;
metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
s3Buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
@@ -588,11 +588,11 @@ export type CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsIt
/**
* @type string
*/
filter_pattern?: string;
filterPattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
logGroupNamePrefix?: string;
};
export interface CloudintegrationtypesAWSLogsStrategyDTO {
@@ -600,7 +600,7 @@ export interface CloudintegrationtypesAWSLogsStrategyDTO {
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
cloudwatchLogsSubscriptions?:
| CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
@@ -621,7 +621,7 @@ export interface CloudintegrationtypesAWSMetricsStrategyDTO {
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
cloudwatchMetricStreamFilters?:
| CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
@@ -726,6 +726,32 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
/**
* @type string
*/
cloudIntegrationId?: string;
config?: CloudintegrationtypesServiceConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
type?: CloudintegrationtypesServiceIDDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
} | null;
export interface CloudintegrationtypesCollectedLogAttributeDTO {
/**
* @type string
@@ -864,9 +890,68 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
} | null;
export type CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesOldAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesOldAWSMetricsStrategyDTO;
/**
* @type string
*/
provider?: string;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
}
export type CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesOldAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesOldAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
/**
* @nullable
*/
@@ -904,6 +989,7 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -917,8 +1003,7 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
/**
* @type string
@@ -930,6 +1015,21 @@ export interface CloudintegrationtypesServiceConfigDTO {
aws: CloudintegrationtypesAWSServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
alb = 'alb',
'api-gateway' = 'api-gateway',
dynamodb = 'dynamodb',
ec2 = 'ec2',
ecs = 'ecs',
eks = 'eks',
elasticache = 'elasticache',
lambda = 'lambda',
msk = 'msk',
rds = 'rds',
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
* @type boolean
@@ -3532,6 +3632,11 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type AgentCheckInPathParameters = {
cloudProvider: string;
};
@@ -3566,10 +3671,6 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**

View File

@@ -0,0 +1,26 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/editOrg';
const editOrg = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/orgs/me`, {
displayName: props.displayName,
});
return {
statusCode: 204,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default editOrg;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getOrganization';
const getOrganization = async (
token?: string,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/org`, {
headers: {
Authorization: `bearer ${token}`,
},
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getOrganization;

View File

@@ -0,0 +1,21 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getUsers';
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -0,0 +1,22 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
const getUser = async (
props: Props,
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getUser;

View File

@@ -0,0 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/editUser';
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/user/${props.userId}`, {
displayName: props.displayName,
role: props.role,
});
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/me`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -1,127 +0,0 @@
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
AuthtypesUserWithRolesDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
getGetUserQueryKey,
invalidateListUsers,
useDeleteUser,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MEMBER_QUERY_PARAMS } from 'container/MembersSettings/constants';
import { MemberStatus, toMemberStatus } from 'container/MembersSettings/utils';
import { parseAsBoolean, useQueryState } from 'nuqs';
function DeleteMemberDialog(): JSX.Element {
const queryClient = useQueryClient();
const [memberId, setMemberId] = useQueryState(MEMBER_QUERY_PARAMS.MEMBER);
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
MEMBER_QUERY_PARAMS.DELETE_MEMBER,
parseAsBoolean.withDefault(false),
);
const open = !!isDeleteOpen && !!memberId;
const cachedUser = memberId
? queryClient.getQueryData<{ data: AuthtypesUserWithRolesDTO }>(
getGetUserQueryKey({ id: memberId }),
)
: null;
const isInvited =
toMemberStatus(cachedUser?.data?.status ?? '') === MemberStatus.Invited;
const displayName = cachedUser?.data?.displayName || cachedUser?.data?.email;
const title = isInvited ? 'Revoke Invite' : 'Delete Member';
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: async (): Promise<void> => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
);
await setIsDeleteOpen(null);
await setMemberId(null);
await invalidateListUsers(queryClient);
},
onError: (error): void => {
const errMessage =
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
},
},
});
function handleConfirm(): void {
if (!memberId) {
return;
}
deleteUser({ pathParams: { id: memberId } });
}
function handleCancel(): void {
setIsDeleteOpen(null);
}
const body = isInvited ? (
<>
Are you sure you want to revoke the invite for <strong>{displayName}</strong>
? They will no longer be able to join the workspace using this invite.
</>
) : (
<>
Are you sure you want to delete <strong>{displayName}</strong>? This will
remove their access to the workspace.
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleCancel();
}
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{body}</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DeleteMemberDialog;

View File

@@ -45,8 +45,8 @@
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: var(--padding-1) var(--padding-2);
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
@@ -57,13 +57,6 @@
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
flex: 1;
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
@@ -85,23 +78,21 @@
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: var(--padding-1) var(--padding-2) !important;
padding: 0 var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 32px;
height: auto !important;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 22px;
line-height: 32px;
letter-spacing: -0.07px;
}

View File

@@ -1,286 +1,119 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
import {
Check,
ChevronDown,
Copy,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Skeleton } from 'antd';
import { Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import {
useGetUser,
useUpdateMyUserV2,
useUpdateUser,
getResetPasswordToken,
useDeleteUser,
useUpdateUserDeprecated,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MEMBER_QUERY_PARAMS } from 'container/MembersSettings/constants';
import { MemberStatus } from 'container/MembersSettings/utils';
import { toMemberRow } from 'container/MembersSettings/utils';
import {
MemberRoleUpdateFailure,
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { parseAsBoolean, parseAsStringEnum, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import DeleteMemberDialog from './DeleteMemberDialog';
import ResetLinkDialog from './ResetLinkDialog';
import { ROLES } from 'types/roles';
import { popupContainer } from 'utils/selectPopupContainer';
import './EditMemberDrawer.styles.scss';
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
function areSortedArraysEqual(a: string[], b: string[]): boolean {
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
}
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
onClose: () => void;
onComplete: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function EditMemberDrawer({ onComplete }: EditMemberDrawerProps): JSX.Element {
function EditMemberDrawer({
member,
open,
onClose,
onComplete,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { user: currentUser } = useAppContext();
const [memberId, setMemberId] = useQueryState(MEMBER_QUERY_PARAMS.MEMBER);
const [, setIsDeleteOpen] = useQueryState(
MEMBER_QUERY_PARAMS.DELETE_MEMBER,
parseAsBoolean.withDefault(false),
);
const [resetLinkType, setResetLinkType] = useQueryState(
MEMBER_QUERY_PARAMS.RESET_LINK,
parseAsStringEnum<'invite' | 'reset'>(['invite', 'reset']),
);
const open = !!memberId && !resetLinkType;
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const {
data: fetchedUser,
isLoading: isFetchingUser,
refetch: refetchUser,
} = useGetUser({ id: memberId ?? '' }, { query: { enabled: !!memberId } });
const member = useMemo(
() => (fetchedUser?.data ? toMemberRow(fetchedUser.data) : null),
[fetchedUser],
);
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
memberId ?? '',
!!memberId,
);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
setSaveErrors([]);
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
setLocalRoles(fetchedRoleIds);
}, [fetchedRoleIds]);
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isSelf = !!memberId && memberId === currentUser?.id;
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
mutation: {
onSuccess: (): void => {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to update member details: ${errMessage}`, {
richColors: true,
});
},
},
});
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: (): void => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
);
setShowDeleteConfirm(false);
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
},
},
});
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
!areSortedArraysEqual(localRoles, fetchedRoleIds));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
const makeRoleRetry = useCallback(
(
context: string,
rawRetry: () => Promise<void>,
) => async (): Promise<void> => {
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
},
[refetchUser],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!memberId) {
return;
}
try {
if (isSelf) {
await updateMyUser({ data: { displayName: localDisplayName } });
} else {
await updateUser({
pathParams: { id: memberId },
data: { displayName: localDisplayName },
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
}, [
memberId,
isSelf,
localDisplayName,
updateMyUser,
updateUser,
refetchUser,
]);
const handleSave = useCallback(async (): Promise<void> => {
if (!memberId || !isDirty) {
return;
}
setSaveErrors([]);
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = !areSortedArraysEqual(localRoles, fetchedRoleIds);
const namePromise = nameChanged
? isSelf
? updateMyUser({ data: { displayName: localDisplayName } })
: updateUser({
pathParams: { id: memberId },
data: { displayName: localDisplayName },
})
: Promise.resolve();
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged ? applyDiff(localRoles, availableRoles) : Promise.resolve([]),
]);
const errors: SaveError[] = [];
if (nameResult.status === 'rejected') {
errors.push({
context: 'Name update',
apiError: toSaveApiError(nameResult.reason),
onRetry: retryNameUpdate,
});
}
if (rolesResult.status === 'rejected') {
errors.push({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(localRoles, availableRoles);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
...rest,
...failures.map((f: MemberRoleUpdateFailure) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
];
});
refetchUser();
},
});
} else {
for (const failure of rolesResult.value ?? []) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
setSaveErrors(errors);
} else {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
}
refetchUser();
} finally {
setIsSaving(false);
}
}, [
memberId,
isDirty,
isSelf,
localDisplayName,
localRoles,
fetchedDisplayName,
fetchedRoleIds,
updateMyUser,
updateUser,
applyDiff,
availableRoles,
refetchUser,
retryNameUpdate,
makeRoleRetry,
onComplete,
]);
const handleClose = useCallback((): void => {
setMemberId(null);
setIsDeleteOpen(null);
setResetLinkType(null);
setSaveErrors([]);
}, [setMemberId, setIsDeleteOpen, setResetLinkType]);
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
(displayName !== (member.name ?? '') || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
@@ -296,139 +129,165 @@ function EditMemberDrawer({ onComplete }: EditMemberDrawerProps): JSX.Element {
[formatTimezoneAdjustedTimestamp],
);
const drawerBody = isFetchingUser ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
const handleSave = useCallback((): void => {
if (!member || !isDirty) {
return;
}
updateUser({
pathParams: { id: member.id },
data: { id: member.id, displayName, role: selectedRole },
});
}, [member, isDirty, displayName, selectedRole, updateUser]);
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
const handleDelete = useCallback((): void => {
if (!member) {
return;
}
deleteUser({
pathParams: { id: member.id },
});
}, [member, deleteUser]);
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf ? (
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="edit-member-drawer__email-text"></span>
)}
</div>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
) : (
<RolesSelect
id="member-role"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRoles}
onChange={(roles): void => {
setLocalRoles(roles);
setSaveErrors((prev) =>
prev.filter(
(err) =>
err.context !== 'Roles update' && !err.context.startsWith("Role '"),
),
);
}}
className="edit-member-drawer__role-select"
placeholder="Select roles"
/>
)}
</div>
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsGeneratingLink(true);
try {
const response = await getResetPasswordToken({ id: member.id });
if (response?.data?.token) {
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
setResetLink(link);
setHasCopiedResetLink(false);
setLinkType(isInvited ? 'invite' : 'reset');
setShowResetLinkDialog(true);
onClose();
} else {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
}
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, setLinkType, onClose]);
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
}, [resetLink, copyToClipboard, linkType]);
{saveErrors.length > 0 && (
<div className="edit-member-drawer__save-errors">
{saveErrors.map((e) => (
<SaveErrorItem
key={e.context}
context={e.context}
apiError={e.apiError}
onRetry={e.onRetry}
/>
))}
</div>
)}
</>
);
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [copyState.error]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);
onClose();
}, [onClose]);
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
@@ -437,12 +296,15 @@ function EditMemberDrawer({ onComplete }: EditMemberDrawerProps): JSX.Element {
<div className="edit-member-drawer__footer-divider" />
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={(): void => {
void setResetLinkType(isInvited ? 'invite' : 'reset');
}}
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isInvited ? 'Copy Invite Link' : 'Generate Password Reset Link'}
{isGeneratingLink
? 'Generating...'
: isInvited
? 'Copy Invite Link'
: 'Generate Password Reset Link'}
</Button>
</div>
@@ -466,6 +328,22 @@ function EditMemberDrawer({ onComplete }: EditMemberDrawerProps): JSX.Element {
</div>
);
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
@@ -485,9 +363,82 @@ function EditMemberDrawer({ onComplete }: EditMemberDrawerProps): JSX.Element {
className="edit-member-drawer"
/>
<DeleteMemberDialog />
<DialogWrapper
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
setLinkType(null);
}
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
<ResetLinkDialog />
<DialogWrapper
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{deleteDialogBody}</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}

View File

@@ -1,186 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { Check, Copy, RefreshCw, TriangleAlert } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Skeleton } from 'antd';
import { getResetPasswordToken } from 'api/generated/services/users';
import { MEMBER_QUERY_PARAMS } from 'container/MembersSettings/constants';
import { parseAsStringEnum, useQueryState } from 'nuqs';
function ResetLinkDialog(): JSX.Element {
const [memberId, setMemberId] = useQueryState(MEMBER_QUERY_PARAMS.MEMBER);
const [linkType, setLinkType] = useQueryState(
MEMBER_QUERY_PARAMS.RESET_LINK,
parseAsStringEnum<'invite' | 'reset'>(['invite', 'reset']),
);
const open = !!linkType && !!memberId;
const [resetLink, setResetLink] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [hasCopied, setHasCopied] = useState(false);
useEffect(() => {
if (!open || !memberId) {
return;
}
let cancelled = false;
async function fetchLink(): Promise<void> {
setIsLoading(true);
setFetchError(null);
setResetLink(null);
setHasCopied(false);
try {
const response = await getResetPasswordToken({ id: memberId as string });
if (cancelled) {
return;
}
if (response?.data?.token) {
setResetLink(
`${window.location.origin}/password-reset?token=${response.data.token}`,
);
} else {
setFetchError('No token returned from server.');
}
} catch {
if (!cancelled) {
setFetchError('Failed to generate link. Please try again.');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void fetchLink();
return (): void => {
cancelled = true;
};
}, [open, memberId]);
const handleClose = useCallback((): void => {
setLinkType(null);
setMemberId(null);
setResetLink(null);
setFetchError(null);
}, [setLinkType, setMemberId]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback((): void => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
const message =
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard';
toast.success(message, { richColors: true });
}, [resetLink, copyToClipboard, linkType]);
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link', { richColors: true });
}
}, [copyState.error]);
const title = linkType === 'invite' ? 'Invite Link' : 'Password Reset Link';
function renderContent(): JSX.Element {
if (isLoading) {
return <Skeleton active paragraph={{ rows: 2 }} />;
}
if (fetchError) {
return (
<div className="reset-link-dialog__error">
<TriangleAlert size={16} className="reset-link-dialog__error-icon" />
<span className="reset-link-dialog__error-text">{fetchError}</span>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => {
if (memberId) {
setFetchError(null);
setIsLoading(true);
getResetPasswordToken({ id: memberId })
.then((response) => {
if (response?.data?.token) {
setResetLink(
`${window.location.origin}/password-reset?token=${response.data.token}`,
);
} else {
setFetchError('No token returned from server.');
}
})
.catch(() => {
setFetchError('Failed to generate link. Please try again.');
})
.finally(() => setIsLoading(false));
}
}}
>
<RefreshCw size={12} />
Retry
</Button>
</div>
);
}
return (
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopy}
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"
>
{hasCopied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
);
}
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
title={title}
showCloseButton
width="base"
className="reset-link-dialog"
>
{renderContent()}
</DialogWrapper>
);
}
export default ResetLinkDialog;

View File

@@ -1,20 +1,14 @@
import type { ReactNode } from 'react';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getResetPasswordToken,
useDeleteUser,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
useUpdateUserDeprecated,
} from 'api/generated/services/users';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
@@ -50,14 +44,8 @@ jest.mock('@signozhq/dialog', () => ({
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUserDeprecated: jest.fn(),
getResetPasswordToken: jest.fn(),
invalidateListUsers: jest.fn().mockResolvedValue(undefined),
getGetUserQueryKey: jest.fn().mockReturnValue(['/api/v2/users/user-1']),
}));
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
@@ -81,80 +69,48 @@ jest.mock('react-use', () => ({
],
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockUpdateMutate = jest.fn();
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const mockFetchedUser = {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [
{
id: 'ur-1',
roleId: managedRoles[0].id,
role: managedRoles[0], // signoz-admin
},
],
},
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
const mockInvitedUser = {
data: {
id: 'abc123',
displayName: '',
email: 'bob@signoz.io',
status: 'pending_invite',
userRoles: [
{
id: 'ur-2',
roleId: managedRoles[2].id,
role: managedRoles[2],
},
],
},
const invitedMember = {
id: 'abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
};
function renderDrawer(
props: Partial<EditMemberDrawerProps> = {},
searchParams: Record<string, string> = { member: 'user-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<EditMemberDrawer onComplete={jest.fn()} {...props} />
</NuqsTestingAdapter>,
<EditMemberDrawer
member={activeMember}
open
onClose={jest.fn()}
onComplete={jest.fn()}
{...props}
/>,
);
}
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
(useGetUser as jest.Mock).mockReturnValue({
data: mockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
mutate: mockUpdateMutate,
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
@@ -163,10 +119,6 @@ describe('EditMemberDrawer', () => {
});
});
afterEach(() => {
server.resetHandlers();
});
it('renders active member details and disables Save when form is not dirty', () => {
renderDrawer();
@@ -178,15 +130,16 @@ describe('EditMemberDrawer', () => {
).toBeDisabled();
});
it('enables Save after editing name and calls updateUser on confirm', async () => {
it('enables Save after editing name and calls update API on confirm', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
});
}));
renderDrawer({ onComplete });
@@ -200,112 +153,48 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { displayName: 'Alice Updated' },
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'user-1' },
data: expect.objectContaining({ displayName: 'Alice Updated' }),
}),
);
expect(onComplete).toHaveBeenCalled();
});
});
it('does not close the drawer after a successful save', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeInTheDocument();
});
});
it('calls setRole when a new role is added', async () => {
it('shows delete confirm dialog and calls deleteUser for active members', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
});
}));
renderDrawer({ onComplete });
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('calls removeRole when an existing role is removed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockRemove = jest.fn().mockResolvedValue({});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemove,
isLoading: false,
});
renderDrawer({ onComplete });
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockRemove).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('opens delete confirm dialog when Delete Member button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
await screen.findByRole('dialog', { name: /delete member/i }),
await screen.findByText(/are you sure you want to delete/i),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /delete member/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('shows revoke invite and copy invite link for invited members; hides Last Modified', () => {
(useGetUser as jest.Mock).mockReturnValue({
data: mockInvitedUser,
isLoading: false,
refetch: jest.fn(),
});
renderDrawer({}, { member: 'abc123' });
renderDrawer({ member: invitedMember });
expect(
screen.getByRole('button', { name: /revoke invite/i }),
@@ -320,19 +209,66 @@ describe('EditMemberDrawer', () => {
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
});
it('opens reset link dialog when Generate Password Reset Link is clicked', async () => {
it('calls deleteUser after confirming revoke invite for invited members', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
renderDrawer({ member: invitedMember, onComplete });
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
// Drawer closes (open = !!memberId && !resetLinkType, so once reset-link param is set it closes)
expect(
screen.queryByRole('button', { name: /save member details/i }),
).not.toBeInTheDocument();
await screen.findByText(/Are you sure you want to revoke the invite/i),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /revoke invite/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
pathParams: { id: 'abc123' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('calls update API when saving changes for an invited member', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
isLoading: false,
}));
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
const nameInput = screen.getByDisplayValue('Bob');
await user.clear(nameInput);
await user.type(nameInput, 'Bob Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'abc123' },
data: expect.objectContaining({ displayName: 'Bob Updated' }),
}),
);
expect(onComplete).toHaveBeenCalled();
});
});
describe('error handling', () => {
@@ -344,13 +280,16 @@ describe('EditMemberDrawer', () => {
} as ReturnType<typeof convertToApiError>);
});
it('shows SaveErrorItem when updateUser fails for name change', async () => {
it('shows API error message when updateUser fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue(new Error('server error')),
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
});
}));
renderDrawer();
@@ -363,10 +302,124 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByText('Name update: Something went wrong on server'),
).toBeInTheDocument();
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to update member details: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for active member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
}));
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
const confirmBtns = screen.getAllByRole('button', {
name: /delete member/i,
});
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to delete member: Something went wrong on server',
expect.anything(),
);
});
});
it('shows API error message when deleteUser fails for invited member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
isLoading: false,
}));
renderDrawer({ member: invitedMember });
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
const confirmBtns = screen.getAllByRole('button', {
name: /revoke invite/i,
});
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to revoke invite: Something went wrong on server',
expect.anything(),
);
});
});
});
describe('Generate Password Reset Link', () => {
beforeEach(() => {
mockCopyToClipboard.mockClear();
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'reset-tok-abc', id: 'user-1' },
});
});
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /password reset link/i,
});
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({
id: 'user-1',
});
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent('reset-tok-abc');
});
it('copies the link to clipboard and shows "Copied!" on the button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /password reset link/i,
});
expect(dialog).toHaveTextContent('reset-tok-abc');
await user.click(screen.getByRole('button', { name: /^copy$/i }));
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
'Reset link copied to clipboard',
expect.anything(),
);
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('reset-tok-abc'),
);
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
});
});
});

View File

@@ -1,176 +0,0 @@
import type { ReactNode } from 'react';
import { toast } from '@signozhq/sonner';
import { getResetPasswordToken } from 'api/generated/services/users';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ResetLinkDialog from '../ResetLinkDialog';
jest.mock('@signozhq/dialog', () => ({
DialogWrapper: ({
children,
open,
title,
}: {
children?: ReactNode;
open: boolean;
title?: string;
}): JSX.Element | null =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
jest.mock('api/generated/services/users', () => ({
getResetPasswordToken: jest.fn(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockCopyToClipboard = jest.fn();
const mockCopyState = { value: undefined, error: undefined };
jest.mock('react-use', () => ({
useCopyToClipboard: (): [typeof mockCopyState, typeof mockCopyToClipboard] => [
mockCopyState,
mockCopyToClipboard,
],
}));
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
function renderDialog(
searchParams: Record<string, string> = {
member: 'user-1',
'reset-link': 'reset',
},
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ResetLinkDialog />
</NuqsTestingAdapter>,
);
}
describe('ResetLinkDialog', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not render when reset-link param is absent', () => {
renderDialog({ member: 'user-1' });
expect(
screen.queryByRole('dialog', { name: /password reset link/i }),
).not.toBeInTheDocument();
});
it('shows loading skeleton while fetching token', () => {
mockGetResetPasswordToken.mockReturnValue(new Promise(() => {})); // never resolves
renderDialog();
expect(
screen.getByRole('dialog', { name: /password reset link/i }),
).toBeInTheDocument();
// Skeleton is rendered — save button not present, link text not present
expect(
screen.queryByRole('button', { name: /copy/i }),
).not.toBeInTheDocument();
});
it('shows the reset link after successful fetch', async () => {
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'reset-tok-abc', id: 'user-1' },
});
renderDialog();
await waitFor(() => {
expect(screen.getByText(/reset-tok-abc/)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument();
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({ id: 'user-1' });
});
it('uses "Invite Link" title and invite description for invite type', async () => {
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'invite-tok-xyz', id: 'user-1' },
});
renderDialog({ member: 'user-1', 'reset-link': 'invite' });
expect(
await screen.findByRole('dialog', { name: /invite link/i }),
).toBeInTheDocument();
expect(
await screen.findByText(/complete their account setup/i),
).toBeInTheDocument();
});
it('shows error state with Retry button when API fails', async () => {
mockGetResetPasswordToken.mockRejectedValue(new Error('network error'));
renderDialog();
await waitFor(() => {
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
expect(screen.getByText(/failed to generate link/i)).toBeInTheDocument();
});
it('retries the API call when Retry button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockGetResetPasswordToken
.mockRejectedValueOnce(new Error('first failure'))
.mockResolvedValue({
status: 'success',
data: { token: 'retry-tok', id: 'user-1' },
});
renderDialog();
await waitFor(() => {
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText(/retry-tok/)).toBeInTheDocument();
});
});
it('copies the link to clipboard and shows "Copied!" on button click', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'reset-tok-abc', id: 'user-1' },
});
renderDialog();
await waitFor(() => {
expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^copy$/i }));
expect(mockCopyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('reset-tok-abc'),
);
expect(mockToast.success).toHaveBeenCalledWith(
'Reset link copied to clipboard',
expect.anything(),
);
});
});

View File

@@ -4,7 +4,9 @@ import { Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
@@ -12,6 +14,7 @@ export interface MemberRow {
id: string;
name?: string;
email: string;
role: ROLES;
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
@@ -138,6 +141,17 @@ function MembersTable({
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
sorter: (a, b): number => a.role.localeCompare(b.role),
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{capitalize(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',

View File

@@ -1,5 +1,6 @@
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import MembersTable, { MemberRow } from '../MembersTable';
@@ -8,6 +9,7 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
},
@@ -15,6 +17,7 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-2',
name: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Active,
joinedOn: null,
},
@@ -24,6 +27,7 @@ const mockInvitedMember: MemberRow = {
id: 'inv-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
};
@@ -43,11 +47,12 @@ describe('MembersTable', () => {
jest.clearAllMocks();
});
it('renders member rows with name, email, and ACTIVE status', () => {
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
});
@@ -62,6 +67,7 @@ describe('MembersTable', () => {
expect(screen.getByText('INVITED')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('calls onRowClick with the member data when a row is clicked', async () => {
@@ -93,6 +99,7 @@ describe('MembersTable', () => {
id: 'user-del',
name: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Deleted,
joinedOn: null,
};

View File

@@ -1,19 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListUsers } from 'api/generated/services/users';
import getAll from 'api/v1/user/get';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { toISOString } from 'utils/app';
import { MEMBER_QUERY_PARAMS } from './constants';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
import './MembersSettings.styles.scss';
@@ -21,6 +21,7 @@ import './MembersSettings.styles.scss';
const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const { org } = useAppContext();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -31,16 +32,20 @@ function MembersSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [, setMemberId] = useQueryState(MEMBER_QUERY_PARAMS.MEMBER);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers();
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const allMembers = useMemo(
(): MemberRow[] =>
(usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email ?? '',
email: user.email,
role: user.role,
status: toMemberStatus(user.status ?? ''),
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user?.updatedAt),
@@ -59,7 +64,9 @@ function MembersSettings(): JSX.Element {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m?.name?.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
m?.name?.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q),
);
}
@@ -131,15 +138,17 @@ function MembersSettings(): JSX.Element {
refetchUsers();
}, [refetchUsers]);
const handleRowClick = useCallback(
(member: MemberRow): void => {
setMemberId(member.id);
},
[setMemberId],
);
const handleRowClick = useCallback((member: MemberRow): void => {
setSelectedMember(member);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedMember(null);
}, []);
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
setSelectedMember(null);
}, [refetchUsers]);
return (
@@ -172,7 +181,7 @@ function MembersSettings(): JSX.Element {
<div className="members-settings__search">
<Input
type="search"
placeholder="Search by name or email..."
placeholder="Search by name, email, or role..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
@@ -212,7 +221,12 @@ function MembersSettings(): JSX.Element {
onComplete={handleInviteComplete}
/>
<EditMemberDrawer onComplete={handleMemberEditComplete} />
<EditMemberDrawer
member={selectedMember}
open={selectedMember !== null}
onClose={handleDrawerClose}
onComplete={handleMemberEditComplete}
/>
</>
);
}

View File

@@ -1,15 +1,6 @@
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
import {
useDeleteUser,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent } from 'tests/test-utils';
import { UserResponse } from 'types/api/user/getUser';
import MembersSettings from '../MembersSettings';
@@ -20,76 +11,51 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
jest.mock('api/generated/services/users', () => ({
useListUsers: jest.requireActual('api/generated/services/users').useListUsers,
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
getResetPasswordToken: jest.fn(),
invalidateListUsers: jest.fn().mockResolvedValue(undefined),
getGetUserQueryKey: jest.fn().mockReturnValue(['/api/v2/users/user-1']),
}));
const USERS_ENDPOINT = '*/api/v1/user';
const USERS_ENDPOINT = '*/api/v2/users';
const mockUsers: TypesUserDTO[] = [
const mockUsers: UserResponse[] = [
{
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN',
status: 'active',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
createdAt: '2024-01-01T00:00:00.000Z',
organization: 'TestOrg',
orgId: 'org-1',
},
{
id: 'user-2',
displayName: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER',
status: 'active',
createdAt: new Date('2024-01-02T00:00:00.000Z'),
createdAt: '2024-01-02T00:00:00.000Z',
organization: 'TestOrg',
orgId: 'org-1',
},
{
id: 'inv-1',
displayName: '',
email: 'charlie@signoz.io',
role: 'EDITOR',
status: 'pending_invite',
createdAt: new Date('2024-01-03T00:00:00.000Z'),
createdAt: '2024-01-03T00:00:00.000Z',
organization: 'TestOrg',
orgId: 'org-1',
},
{
id: 'user-3',
displayName: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER',
status: 'deleted',
createdAt: new Date('2024-01-04T00:00:00.000Z'),
createdAt: '2024-01-04T00:00:00.000Z',
organization: 'TestOrg',
orgId: 'org-1',
},
];
const USER_ENDPOINT = '*/api/v2/users/user-1';
const mockFetchedUser = {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [],
},
};
function renderPage(): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter hasMemory>
<MembersSettings />
</NuqsTestingAdapter>,
);
}
describe('MembersSettings (integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -97,31 +63,7 @@ describe('MembersSettings (integration)', () => {
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockUsers })),
),
rest.get(USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockFetchedUser)),
),
);
(useGetUser as jest.Mock).mockReturnValue({
data: mockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
});
(useDeleteUser as jest.Mock).mockReturnValue({
mutate: jest.fn(),
isLoading: false,
});
});
afterEach(() => {
@@ -129,7 +71,7 @@ describe('MembersSettings (integration)', () => {
});
it('loads and displays active users, pending invites, and deleted members', async () => {
renderPage();
render(<MembersSettings />);
await screen.findByText('Alice Smith');
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
@@ -143,7 +85,7 @@ describe('MembersSettings (integration)', () => {
it('filters to pending invites via the filter dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
render(<MembersSettings />);
await screen.findByText('Alice Smith');
@@ -159,12 +101,12 @@ describe('MembersSettings (integration)', () => {
it('filters members by name using the search input', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
render(<MembersSettings />);
await screen.findByText('Alice Smith');
await user.type(
screen.getByPlaceholderText(/Search by name or email/i),
screen.getByPlaceholderText(/Search by name, email, or role/i),
'bob',
);
@@ -176,7 +118,7 @@ describe('MembersSettings (integration)', () => {
it('opens EditMemberDrawer when an active member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
render(<MembersSettings />);
await user.click(await screen.findByText('Alice Smith'));
@@ -186,7 +128,7 @@ describe('MembersSettings (integration)', () => {
it('does not open EditMemberDrawer when a deleted member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
render(<MembersSettings />);
await user.click(await screen.findByText('Dave Deleted'));
@@ -196,7 +138,7 @@ describe('MembersSettings (integration)', () => {
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
render(<MembersSettings />);
await user.click(screen.getByRole('button', { name: /invite member/i }));

View File

@@ -1,5 +0,0 @@
export const MEMBER_QUERY_PARAMS = {
MEMBER: 'member',
DELETE_MEMBER: 'delete-member',
RESET_LINK: 'reset-link',
} as const;

View File

@@ -1,18 +1,3 @@
import type { AuthtypesUserWithRolesDTO } from 'api/generated/services/sigNoz.schemas';
import type { MemberRow } from 'components/MembersTable/MembersTable';
import { toISOString } from 'utils/app';
export function toMemberRow(user: AuthtypesUserWithRolesDTO): MemberRow {
return {
id: user.id,
name: user.displayName,
email: user.email ?? '',
status: toMemberStatus(user.status ?? ''),
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user.updatedAt),
};
}
export enum FilterMode {
All = 'all',
Invited = 'invited',

View File

@@ -2,8 +2,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useUpdateMyUserV2 } from 'api/generated/services/users';
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import editUser from 'api/v1/user/id/update';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
@@ -17,7 +17,6 @@ function UserInfo(): JSX.Element {
const { t } = useTranslation(['routes', 'settings', 'common']);
const { notifications } = useNotifications();
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
@@ -93,7 +92,10 @@ function UserInfo(): JSX.Element {
);
try {
setIsLoading(true);
await updateMyUser({ data: { displayName: changedName } });
await editUser({
displayName: changedName,
userId: user.id,
});
notifications.success({
message: t('success', {

View File

@@ -22,12 +22,9 @@ jest.mock('react-use', () => ({
],
}));
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useUpdateMyUserV2: jest.fn(() => ({
mutateAsync: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
isLoading: false,
})),
jest.mock('api/v1/user/id/update', () => ({
__esModule: true,
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
}));
jest.mock('hooks/useDarkMode', () => ({

View File

@@ -1,10 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import editOrg from 'api/organization/editOrg';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
@@ -16,34 +14,42 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const { t } = useTranslation(['organizationsettings', 'common']);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const {
mutateAsync: updateMyOrganization,
isLoading,
} = useUpdateMyOrganization({
mutation: {
onSuccess: (_, { data }) => {
toast.success(t('success', { ns: 'common' }), {
richColors: true,
position: 'top-right',
});
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
);
},
},
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const onSubmit = async (values: FormValues): Promise<void> => {
const { displayName } = values;
await updateMyOrganization({ data: { id: orgId, displayName } });
try {
setIsLoading(true);
const { displayName } = values;
const { statusCode, error } = await editOrg({
displayName,
orgId,
});
if (statusCode === 204) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
updateOrg(orgId, displayName);
} else {
notifications.error({
message:
error ||
t('something_went_wrong', {
ns: 'common',
}),
});
}
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: t('something_went_wrong', {
ns: 'common',
}),
});
}
};
if (!org) {

View File

@@ -1,101 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
} from 'api/generated/services/users';
export interface MemberRoleUpdateFailure {
roleName: string;
error: unknown;
onRetry: () => Promise<void>;
}
interface UseMemberRoleManagerResult {
fetchedRoleIds: string[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const { data: fetchedUser, isLoading } = useGetUser(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const currentUserRoles = useMemo(() => fetchedUser?.data?.userRoles ?? [], [
fetchedUser,
]);
const fetchedRoleIds = useMemo(
() =>
currentUserRoles
.map((ur) => ur.role?.id ?? ur.roleId)
.filter((id): id is string => Boolean(id)),
[currentUserRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID();
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID();
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIdSet = new Set(fetchedRoleIds);
const desiredRoleIdSet = new Set(localRoleIds.filter(Boolean));
const toRemove = currentUserRoles.filter((ur) => {
const id = ur.role?.id ?? ur.roleId;
return id && !desiredRoleIdSet.has(id);
});
const toAdd = availableRoles.filter(
(r) => r.id && desiredRoleIdSet.has(r.id) && !currentRoleIdSet.has(r.id),
);
const allOps = [
...toRemove.map((ur) => ({
roleName: ur.role?.name ?? 'unknown',
run: (): ReturnType<typeof removeRole> =>
removeRole({
pathParams: {
id: userId,
roleId: ur.role?.id ?? ur.roleId ?? '',
},
}),
})),
...toAdd.map((role) => ({
roleName: role.name ?? 'unknown',
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
];
const results = await Promise.allSettled(allOps.map((op) => op.run()));
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, i) => {
if (result.status === 'rejected') {
const { roleName, run } = allOps[i];
failures.push({ roleName, error: result.reason, onRetry: run });
}
});
return failures;
},
[userId, fetchedRoleIds, currentUserRoles, setRole, removeRole],
);
return { fetchedRoleIds, isLoading, applyDiff };
}

View File

@@ -0,0 +1,15 @@
import { useQuery, UseQueryResult } from 'react-query';
import getUser from 'api/v1/user/id/get';
import { SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
const useGetUser = (userId: string, isLoggedIn: boolean): UseGetUser =>
useQuery({
queryFn: () => getUser({ userId }),
queryKey: [userId],
enabled: !!userId && !!isLoggedIn,
});
type UseGetUser = UseQueryResult<SuccessResponseV2<UserResponse>, unknown>;
export default useGetUser;

View File

@@ -12,9 +12,8 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetMyOrganization } from 'api/generated/services/orgs';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import get from 'api/v1/user/me/get';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -41,9 +40,7 @@ import {
UserPreference,
} from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { ROLES, USER_ROLES } from 'types/roles';
import { toISOString } from 'utils/app';
import { IAppContext, IUser } from './types';
import { getUserDefaults } from './utils';
@@ -74,23 +71,17 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// fetcher for current user
// fetcher for user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
const {
data: userData,
isFetching: isFetchingUserData,
error: userFetchDataError,
} = useGetMyUser({
query: { enabled: isLoggedIn },
});
const {
data: orgData,
isFetching: isFetchingOrgData,
error: orgFetchDataError,
} = useGetMyOrganization({
query: { enabled: isLoggedIn },
} = useQuery({
queryFn: get,
queryKey: ['/api/v1/user/me'],
enabled: isLoggedIn,
});
const {
@@ -102,10 +93,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: isLoggedIn,
});
const isFetchingUser =
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
const userFetchError =
userFetchDataError || orgFetchDataError || errorOnPermissions;
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
@@ -129,55 +118,38 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
}, [defaultUser, userRole]);
useEffect(() => {
if (!isFetchingUserData && userData?.data) {
setLocalStorageApi(
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
userData.data.email ?? '',
);
if (!isFetchingUser && userData && userData.data) {
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, userData.data.email);
setDefaultUser((prev) => ({
...prev,
id: userData.data.id,
displayName: userData.data.displayName ?? prev.displayName,
email: userData.data.email ?? prev.email,
orgId: userData.data.orgId ?? prev.orgId,
isRoot: userData.data.isRoot,
status: userData.data.status as UserResponse['status'],
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
...userData.data,
}));
}
}, [userData, isFetchingUserData]);
useEffect(() => {
if (!isFetchingOrgData && orgData?.data) {
const { id: orgId, displayName: orgDisplayName } = orgData.data;
setOrg((prev) => {
if (!prev) {
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
}
const orgIndex = prev.findIndex((e) => e.id === orgId);
if (orgIndex === -1) {
// if no org is present enter a new entry
return [
...prev,
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
{
createdAt: 0,
id: userData.data.orgId,
displayName: userData.data.organization,
},
];
}
// else mutate the existing entry
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
const updatedOrg: Organization[] = [
...prev.slice(0, orgIndex),
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
...prev.slice(orgIndex + 1),
{
createdAt: 0,
id: userData.data.orgId,
displayName: userData.data.organization,
},
...prev.slice(orgIndex + 1, prev.length),
];
return updatedOrg;
});
setDefaultUser((prev) => ({
...prev,
organization: orgDisplayName ?? prev.organization,
}));
}
}, [orgData, isFetchingOrgData]);
}, [userData, isFetchingUser]);
// fetcher for licenses v3
const {
@@ -301,9 +273,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
(orgId: string, updatedOrgName: string): void => {
if (org && org.length > 0) {
const orgIndex = org.findIndex((e) => e.id === orgId);
if (orgIndex === -1) {
return;
}
const updatedOrg: Organization[] = [
...org.slice(0, orgIndex),
{
@@ -311,7 +280,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
id: orgId,
displayName: updatedOrgName,
},
...org.slice(orgIndex + 1),
...org.slice(orgIndex + 1, org.length),
];
setOrg(updatedOrg);
setDefaultUser((prev) => {

View File

@@ -2,7 +2,7 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import type {
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -15,8 +15,6 @@ import { USER_ROLES } from 'types/roles';
import { AppProvider, useAppContext } from '../App';
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
const MY_USER_URL = 'http://localhost/api/v2/users/me';
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
@@ -229,132 +227,6 @@ describe('AppProvider user.role from permissions', () => {
});
});
describe('AppProvider user and org data from v2 APIs', () => {
beforeEach(() => {
queryClient.clear();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
});
it('populates user fields from GET /api/v2/users/me', async () => {
server.use(
rest.get(MY_USER_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
id: 'u-123',
displayName: 'Test User',
email: 'test@signoz.io',
orgId: 'org-abc',
isRoot: false,
status: 'active',
},
}),
),
),
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'org-abc', displayName: 'My Org' } }),
),
),
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.user.id).toBe('u-123');
expect(result.current.user.displayName).toBe('Test User');
expect(result.current.user.email).toBe('test@signoz.io');
expect(result.current.user.orgId).toBe('org-abc');
},
{ timeout: 2000 },
);
});
it('populates org state from GET /api/v2/orgs/me', async () => {
server.use(
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
id: 'org-abc',
displayName: 'My Org',
},
}),
),
),
rest.get(MY_USER_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'u-default', email: 'default@signoz.io' } }),
),
),
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.org).not.toBeNull();
const org = result.current.org?.[0];
expect(org?.id).toBe('org-abc');
expect(org?.displayName).toBe('My Org');
},
{ timeout: 2000 },
);
});
it('sets isFetchingUser false once both user and org calls complete', async () => {
server.use(
rest.get(MY_USER_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { id: 'u-1', email: 'a@b.com' } })),
),
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'org-1', displayName: 'Org' } }),
),
),
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isFetchingUser).toBe(false);
},
{ timeout: 2000 },
);
});
});
describe('AppProvider when authz/check fails', () => {
beforeEach(() => {
queryClient.clear();

View File

@@ -59,7 +59,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
Response: new(citypes.Account),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
@@ -139,7 +139,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableService),
Response: new(citypes.Service),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -150,7 +150,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",

View File

@@ -13,16 +13,16 @@ type Module interface {
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
@@ -30,17 +30,20 @@ type Module interface {
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
// CreateService creates a new service for a cloud integration account.
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
@@ -50,6 +53,39 @@ type Module interface {
// ListDashboards returns list of dashboards across all connected cloud integration accounts
// for enabled services in the org. This list gets added to dashboard list page
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
// GetCloudProvider returns cloud provider specific module
GetCloudProvider(provider citypes.CloudProviderType) (CloudProviderModule, error)
}
type CloudProviderModule interface {
GetConnectionArtifact(ctx context.Context, creds *citypes.SignozCredentials, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServiceDefinitions returns all service definitions for this cloud provider.
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
// GetServiceDefinition returns the service definition for the given service ID.
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
// IsServiceEnabled returns true if the service has at least one signal (logs or metrics) enabled.
IsServiceEnabled(ctx context.Context, config *citypes.ServiceConfig) bool
// IsMetricsEnabled returns true if the service config has metrics explicitly enabled.
// Used to gate dashboard availability — dashboards are only shown when metrics are enabled.
IsMetricsEnabled(ctx context.Context, config *citypes.ServiceConfig) bool
// ServiceConfigFromStorableServiceConfig converts a stored service's provider-specific JSON config
// into a *ServiceConfig wrapper.
ServiceConfigFromStorableServiceConfig(ctx context.Context, config string) (*citypes.ServiceConfig, error)
// StorableConfigFromServiceConfig serializes a ServiceConfig into the raw provider-specific
// JSON string suitable for storing in StorableCloudIntegrationService.Config.
// It strips any signal config (logs/metrics) that the service's SupportedSignals declares unsupported.
StorableConfigFromServiceConfig(ctx context.Context, cfg *citypes.ServiceConfig, supported citypes.SupportedSignals) (string, error)
// BuildIntegrationConfig compiles the provider-specific integration config from the account
// and list of configured services. This is the config returned to the agent on check-in.
BuildIntegrationConfig(ctx context.Context, account *citypes.Account, services []*citypes.StorableCloudIntegrationService) (*citypes.ProviderIntegrationConfig, error)
}
type Handler interface {

View File

@@ -1,21 +1,174 @@
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
}
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.Strategy == 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)
}
}

View File

@@ -1,58 +1,425 @@
package implcloudintegration
import (
"context"
"net/http"
"time"
"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) CreateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func NewHandler(module cloudintegration.Module) cloudintegration.Handler {
return &handler{
module: module,
}
}
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
}
postableConnectionArtifact := new(cloudintegrationtypes.PostableConnectionArtifact)
err = binding.JSON.BindBody(r.Body, postableConnectionArtifact)
if err != nil {
render.Error(rw, err)
return
}
if err := postableConnectionArtifact.Validate(provider); err != nil {
render.Error(rw, err)
return
}
accountConfig, err := cloudintegrationtypes.NewAccountConfigFromPostableArtifact(provider, postableConnectionArtifact)
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
}
connectionArtifactRequest, err := cloudintegrationtypes.NewArtifactRequestFromPostableArtifact(provider, postableConnectionArtifact)
if err != nil {
render.Error(rw, err)
return
}
connectionArtifact, err := handler.module.GetConnectionArtifact(ctx, account, connectionArtifactRequest)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, &cloudintegrationtypes.GettableAccountWithArtifact{
ID: account.ID,
Artifact: 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
}
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.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
}
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
}
cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
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.PostableAgentCheckInRequest)
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.NewGettableAgentCheckInResponse(provider, resp))
}

View 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) 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.ConnectionArtifactRequest) (*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")
}
func (module *module) GetCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get cloud provider is not supported")
}

View File

@@ -172,3 +172,9 @@ func (store *store) UpdateService(ctx context.Context, service *cloudintegration
Exec(ctx)
return err
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.store.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,9 +20,13 @@ import (
"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"
@@ -41,6 +45,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"
@@ -94,6 +99,7 @@ func New(
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],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
cloudIntegrationCallback func(cloudintegrationtypes.Store, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module) (cloudintegration.Module, error),
) (*SigNoz, error) {
// Initialize instrumentation
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
@@ -411,11 +417,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, zeus, gateway, licensing, serviceAccount)
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

View File

@@ -1,8 +1,10 @@
package cloudintegrationtypes
import (
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -33,8 +35,6 @@ type GettableAccounts struct {
Accounts []*Account `json:"accounts" required:"true" nullable:"false"`
}
type GettableAccount = Account
type UpdatableAccount struct {
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
@@ -42,3 +42,152 @@ type UpdatableAccount struct {
type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
}
func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountConfig) *Account {
return &Account{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
OrgID: orgID,
Provider: provider,
Config: config,
}
}
func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account, error) {
// config can not be empty
if storableAccount.Config == "" {
return nil, errors.NewInternalf(errors.CodeInternal, "config is empty for account with id: %s", storableAccount.ID)
}
account := &Account{
Identifiable: storableAccount.Identifiable,
TimeAuditable: storableAccount.TimeAuditable,
ProviderAccountID: storableAccount.AccountID,
Provider: storableAccount.Provider,
RemovedAt: storableAccount.RemovedAt,
OrgID: storableAccount.OrgID,
Config: new(AccountConfig),
}
switch storableAccount.Provider {
case CloudProviderTypeAWS:
awsConfig := new(AWSAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), awsConfig)
if err != nil {
return nil, err
}
account.Config.AWS = awsConfig
}
if storableAccount.LastAgentReport != nil {
account.AgentReport = &AgentReport{
TimestampMillis: storableAccount.LastAgentReport.TimestampMillis,
Data: storableAccount.LastAgentReport.Data,
}
}
return account, nil
}
func NewAccountsFromStorables(storableAccounts []*StorableCloudIntegration) ([]*Account, error) {
accounts := make([]*Account, 0, len(storableAccounts))
for _, storableAccount := range storableAccounts {
account, err := NewAccountFromStorable(storableAccount)
if err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, nil
}
func (account *Account) Update(config *AccountConfig) error {
if account.RemovedAt != nil {
return errors.New(errors.TypeUnsupported, ErrCodeCloudIntegrationRemoved, "this operation is not supported for a removed cloud integration account")
}
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
func NewAccountConfigFromPostableArtifact(provider CloudProviderType, artifact *PostableConnectionArtifact) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
if artifact.Aws == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "AWS artifact is nil")
}
return &AccountConfig{
AWS: &AWSAccountConfig{
Regions: artifact.Aws.Regions,
},
}, nil
}
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
func NewArtifactRequestFromPostableArtifact(provider CloudProviderType, artifact *PostableConnectionArtifact) (*ConnectionArtifactRequest, error) {
switch provider {
case CloudProviderTypeAWS:
if artifact.Aws == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "AWS artifact is nil")
}
return &ConnectionArtifactRequest{
Aws: &AWSConnectionArtifactRequest{
DeploymentRegion: artifact.Aws.DeploymentRegion,
Regions: artifact.Aws.Regions,
},
}, nil
}
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
func (updatable *UpdatableAccount) Validate(provider CloudProviderType) error {
if updatable.Config == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"config is required")
}
switch provider {
case CloudProviderTypeAWS:
if updatable.Config.AWS == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
if len(updatable.Config.AWS.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range updatable.Config.AWS.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput,
"invalid cloud provider: %s", provider.StringValue())
}
return nil
}
// ToJSON return JSON bytes for the provider's config
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
// NOTE: this entertains first non-null provider's config.
func (config *AccountConfig) ToJSON() ([]byte, error) {
if config.AWS != nil {
return json.Marshal(config.AWS)
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}

View File

@@ -13,10 +13,15 @@ import (
)
var (
ErrCodeUnsupported = errors.MustNewCode("cloud_integration_unsupported")
ErrCodeCloudIntegrationNotFound = errors.MustNewCode("cloud_integration_not_found")
ErrCodeCloudIntegrationAlreadyExists = errors.MustNewCode("cloud_integration_already_exists")
ErrCodeCloudIntegrationAlreadyConnected = errors.MustNewCode("cloud_integration_already_connected")
ErrCodeCloudIntegrationRemoved = errors.MustNewCode("cloud_integration_removed")
ErrCodeInvalidInput = errors.MustNewCode("cloud_integration_invalid_input")
ErrCodeCloudIntegrationServiceNotFound = errors.MustNewCode("cloud_integration_service_not_found")
ErrCodeCloudIntegrationServiceAlreadyExists = errors.MustNewCode("cloud_integration_service_already_exists")
ErrCodeCloudIntegrationServiceAlreadyExists = errors.MustNewCode("cloud_integration_service_already_exists")
ErrCodeServiceDefinitionNotFound = errors.MustNewCode("service_definition_not_found")
)
// StorableCloudIntegration represents a cloud integration stored in the database.
@@ -68,10 +73,6 @@ func (r *StorableAgentReport) Scan(src any) error {
// Value creates value to be stored in DB.
func (r *StorableAgentReport) Value() (driver.Value, error) {
if r == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "agent report is nil")
}
serialized, err := json.Marshal(r)
if err != nil {
return nil, errors.WrapInternalf(
@@ -81,3 +82,41 @@ func (r *StorableAgentReport) Value() (driver.Value, error) {
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytes
return string(serialized), nil
}
func NewStorableCloudIntegration(account *Account) (*StorableCloudIntegration, error) {
configBytes, err := account.Config.ToJSON()
if err != nil {
return nil, err
}
storableAccount := &StorableCloudIntegration{
Identifiable: account.Identifiable,
TimeAuditable: account.TimeAuditable,
Provider: account.Provider,
Config: string(configBytes),
AccountID: account.ProviderAccountID,
OrgID: account.OrgID,
RemovedAt: account.RemovedAt,
}
if account.AgentReport != nil {
storableAccount.LastAgentReport = &StorableAgentReport{
TimestampMillis: account.AgentReport.TimestampMillis,
Data: account.AgentReport.Data,
}
}
return storableAccount, nil
}
// NewStorableCloudIntegrationService creates a new StorableCloudIntegrationService with
// generated ID and timestamps from a CloudIntegrationService and its serialized config JSON.
func NewStorableCloudIntegrationService(svc *CloudIntegrationService, configJSON string) *StorableCloudIntegrationService {
return &StorableCloudIntegrationService{
Identifiable: svc.Identifiable,
TimeAuditable: svc.TimeAuditable,
Type: svc.Type,
Config: configJSON,
CloudIntegrationID: svc.CloudIntegrationID,
}
}

View File

@@ -1,6 +1,8 @@
package cloudintegrationtypes
import (
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -14,7 +16,7 @@ var (
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
// errors.
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
AWSIntegrationUserEmail = valuer.MustNewEmail("aws-integration@signoz.io")
AzureIntegrationUserEmail = valuer.MustNewEmail("azure-integration@signoz.io")
@@ -39,3 +41,29 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}
func GetCloudProviderEmail(provider CloudProviderType) (valuer.Email, error) {
switch provider {
case CloudProviderTypeAWS:
return AWSIntegrationUserEmail, nil
case CloudProviderTypeAzure:
return AzureIntegrationUserEmail, nil
default:
return valuer.Email{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func NewIngestionKeyName(provider CloudProviderType) string {
return fmt.Sprintf("%s-integration", provider.StringValue())
}
func NewIntegrationUserDisplayName(provider CloudProviderType) string {
return fmt.Sprintf("%s-integration", provider.StringValue())
}
// NewAPIKeyName returns API key name for cloud integration provider
// TODO: figure out way to migrate API keys to have similar naming convention as ingestion key
// ie. "{cloud-provider}-integration", and then remove this function.
func NewAPIKeyName(provider CloudProviderType) string {
return fmt.Sprintf("%s integration", provider.StringValue())
}

View File

@@ -3,6 +3,7 @@ package cloudintegrationtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -33,8 +34,8 @@ type GettableAccountWithArtifact struct {
}
type AgentCheckInRequest struct {
ProviderAccountID string `json:"providerAccountId" required:"false"`
CloudIntegrationID string `json:"cloudIntegrationId" required:"false"`
ProviderAccountID string `json:"providerAccountId" required:"false"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId" required:"false"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
}
@@ -67,8 +68,8 @@ type GettableAgentCheckInResponse struct {
// IntegrationConfig older integration config struct for backward compatibility,
// this will be eventually removed once agents are updated to use new struct.
type IntegrationConfig struct {
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *OldAWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
}
type ProviderIntegrationConfig struct {
@@ -79,3 +80,87 @@ type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
Telemetry *AWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"`
}
type SignozCredentials struct {
SigNozAPIURL string
SigNozAPIKey string // PAT
IngestionURL string
IngestionKey string
}
// NewGettableAgentCheckInResponse constructs a backward-compatible response from an AgentCheckInResponse.
// It populates the old snake_case fields (account_id, cloud_account_id, integration_config, removed_at)
// from the new camelCase fields so older agents continue to work unchanged.
// The provider parameter controls which provider-specific block is mapped into the legacy integration_config.
func NewGettableAgentCheckInResponse(provider CloudProviderType, resp *AgentCheckInResponse) *GettableAgentCheckInResponse {
gettable := &GettableAgentCheckInResponse{
AccountID: resp.CloudIntegrationID,
CloudAccountID: resp.ProviderAccountID,
OlderRemovedAt: resp.RemovedAt,
AgentCheckInResponse: *resp,
}
switch provider {
case CloudProviderTypeAWS:
gettable.OlderIntegrationConfig = awsOlderIntegrationConfig(resp.IntegrationConfig)
}
return gettable
}
// Validate checks that the connection artifact request has a valid provider-specific block
// with non-empty, valid regions and a valid deployment region.
func (req *ConnectionArtifactRequest) Validate(provider CloudProviderType) error {
switch provider {
case CloudProviderTypeAWS:
if req.Aws == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
return req.Aws.Validate()
}
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput,
"invalid cloud provider: %s", provider)
}
// Validate checks that the AWS connection artifact request has a valid deployment region
// and a non-empty list of valid regions.
func (req *AWSConnectionArtifactRequest) Validate() error {
if req.DeploymentRegion == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"deploymentRegion is required")
}
if _, ok := ValidAWSRegions[req.DeploymentRegion]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid deployment region: %s", req.DeploymentRegion)
}
if len(req.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range req.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
return nil
}
// Validate checks that the request uses either old fields (account_id, cloud_account_id) or
// new fields (cloudIntegrationId, providerAccountId), never a mix of both.
func (req *PostableAgentCheckInRequest) Validate() error {
hasOldFields := req.ID != "" || req.AccountID != ""
hasNewFields := !req.CloudIntegrationID.IsZero() || req.ProviderAccountID != ""
if hasOldFields && hasNewFields {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"request must use either old fields (account_id, cloud_account_id) or new fields (cloudIntegrationId, providerAccountId), not both")
}
if !hasOldFields && !hasNewFields {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"request must provide either old fields (account_id, cloud_account_id) or new fields (cloudIntegrationId, providerAccountId)")
}
return nil
}

View File

@@ -4,10 +4,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
ErrCodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
)
var ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
// List of all valid cloud regions on Amazon Web Services.
var ValidAWSRegions = map[string]struct{}{

View File

@@ -2,6 +2,7 @@ package cloudintegrationtypes
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -45,22 +46,28 @@ type GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
}
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
ServiceDefinition
ServiceConfig *ServiceConfig `json:"serviceConfig" required:"false" nullable:"false"`
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type GettableService = Service
type UpdatableService struct {
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
}
// Update sets the service config.
func (service *CloudIntegrationService) Update(config *ServiceConfig) {
service.Config = config
service.UpdatedAt = time.Now()
}
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
Assets Assets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supported_signals" required:"true"`
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
@@ -121,19 +128,38 @@ type CollectedMetric struct {
}
// AWSCollectionStrategy represents signal collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSCollectionStrategy struct {
Metrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *AWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
Metrics *AWSMetricsStrategy `json:"metrics,omitempty"`
Logs *AWSLogsStrategy `json:"logs,omitempty"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
}
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {
Provider string `json:"provider"`
Metrics *OldAWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *OldAWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
// OldAWSMetricsStrategy is the snake_case form of AWSMetricsStrategy for older agents.
type OldAWSMetricsStrategy struct {
StreamFilters []struct {
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// OldAWSLogsStrategy is the snake_case form of AWSLogsStrategy for older agents.
type OldAWSLogsStrategy struct {
Subscriptions []struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSMetricsStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []struct {
@@ -141,23 +167,20 @@ type AWSMetricsStrategy struct {
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
} `json:"cloudwatchMetricStreamFilters"`
}
// AWSLogsStrategy represents logs collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSLogsStrategy struct {
Subscriptions []struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"log_group_name_prefix"`
LogGroupNamePrefix string `json:"logGroupNamePrefix"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required.
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
FilterPattern string `json:"filterPattern"`
} `json:"cloudwatchLogsSubscriptions"`
}
// Dashboard represents a dashboard definition for cloud integration.
@@ -170,6 +193,95 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Type: serviceID,
Config: config,
CloudIntegrationID: cloudIntegrationID,
}
}
func NewCloudIntegrationServiceFromStorable(stored *StorableCloudIntegrationService, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: stored.Identifiable,
TimeAuditable: stored.TimeAuditable,
Type: stored.Type,
Config: config,
CloudIntegrationID: stored.CloudIntegrationID,
}
}
// awsOlderIntegrationConfig converts a ProviderIntegrationConfig into the legacy snake_case
// IntegrationConfig format consumed by older AWS agents. Returns nil if AWS config is absent.
func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfig {
if cfg == nil || cfg.AWS == nil {
return nil
}
awsCfg := cfg.AWS
older := &IntegrationConfig{
EnabledRegions: awsCfg.EnabledRegions,
}
if awsCfg.Telemetry == nil {
return older
}
// Older agents expect a "provider" field and fully snake_case keys inside telemetry.
oldTelemetry := &OldAWSCollectionStrategy{
Provider: CloudProviderTypeAWS.StringValue(),
S3Buckets: awsCfg.Telemetry.S3Buckets,
}
if awsCfg.Telemetry.Metrics != nil {
// Convert camelCase cloudwatchMetricStreamFilters → snake_case cloudwatch_metric_stream_filters
oldMetrics := &OldAWSMetricsStrategy{}
for _, f := range awsCfg.Telemetry.Metrics.StreamFilters {
oldMetrics.StreamFilters = append(oldMetrics.StreamFilters, struct {
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
}{Namespace: f.Namespace, MetricNames: f.MetricNames})
}
oldTelemetry.Metrics = oldMetrics
}
if awsCfg.Telemetry.Logs != nil {
// Convert camelCase cloudwatchLogsSubscriptions → snake_case cloudwatch_logs_subscriptions
oldLogs := &OldAWSLogsStrategy{}
for _, s := range awsCfg.Telemetry.Logs.Subscriptions {
oldLogs.Subscriptions = append(oldLogs.Subscriptions, struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`
FilterPattern string `json:"filter_pattern"`
}{LogGroupNamePrefix: s.LogGroupNamePrefix, FilterPattern: s.FilterPattern})
}
oldTelemetry.Logs = oldLogs
}
older.Telemetry = oldTelemetry
return older
}
func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMetadata {
return &ServiceMetadata{
ServiceDefinitionMetadata: definition.ServiceDefinitionMetadata,
Enabled: enabled,
}
}
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
return &Service{
ServiceDefinition: def,
CloudIntegrationService: storableService,
}
}
// UTILS
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
@@ -178,6 +290,20 @@ func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcID, dash
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcID, dashboardID)
}
// ParseCloudIntegrationDashboardID parses a dashboard id generated by GetCloudIntegrationDashboardID
// into its constituent parts (cloudProvider, serviceID, dashboardID).
func ParseCloudIntegrationDashboardID(id string) (CloudProviderType, string, string, error) {
parts := strings.SplitN(id, "--", 4)
if len(parts) != 4 || parts[0] != "cloud-integration" {
return CloudProviderType{}, "", "", errors.New(errors.TypeNotFound, ErrCodeCloudIntegrationNotFound, "invalid cloud integration dashboard id")
}
provider, err := NewCloudProvider(parts[1])
if err != nil {
return CloudProviderType{}, "", "", err
}
return provider, parts[2], parts[3], nil
}
// GetDashboardsFromAssets returns the list of dashboards for the cloud provider service from definition.
func GetDashboardsFromAssets(
svcID string,

View File

@@ -38,6 +38,8 @@ type Store interface {
// UpdateService updates an existing cloud integration service
UpdateService(ctx context.Context, service *StorableCloudIntegrationService) error
RunInTx(context.Context, func(ctx context.Context) error) error
}
type ServiceDefinitionStore interface {

View File

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

View File

@@ -8,13 +8,14 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrationsutils import setup_create_account_mocks
from fixtures.logger import setup_logger
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 +79,74 @@ 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]] = []
make_http_mocks = request.getfixturevalue("make_http_mocks")
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"]
setup_create_account_mocks(signoz, make_http_mocks)
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
cloud_provider: {
"deploymentRegion": deployment_region,
"regions": regions,
}
}
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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
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={"gcp": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}},
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"

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

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

View File

@@ -0,0 +1,335 @@
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}"