Compare commits

..

30 Commits

Author SHA1 Message Date
Swapnil Nakade
989ca522f8 refactor: updating azure telemetry collections strat 2026-02-12 14:54:24 +05:30
Swapnil Nakade
9a2e9d76b5 feat: updating service definitions 2026-02-10 20:11:40 +05:30
Swapnil Nakade
2be42deecd Merge branch 'main' into feat/azure-integration 2026-02-10 18:44:40 +05:30
Swapnil Nakade
95cad880cc feat: adding default values for azure service config 2026-02-10 18:21:26 +05:30
Swapnil Nakade
cfef1091b3 Merge branch 'main' into feat/azure-integration 2026-02-09 15:38:17 +05:30
Swapnil Nakade
4504c364f2 feat: adding comments 2026-02-09 15:37:10 +05:30
Swapnil Nakade
1a006870e1 refactor: removing wrong deps 2026-02-09 15:24:02 +05:30
Swapnil Nakade
e7a27a1cfb Merge branch 'main' into feat/azure-integration 2026-02-09 15:19:36 +05:30
Swapnil Nakade
1e7323ead2 refactor: updating response for conn status 2026-02-08 22:56:04 +05:30
Swapnil Nakade
af4c6c5b52 Merge branch 'main' into feat/azure-integration 2026-02-08 18:24:47 +05:30
Swapnil Nakade
02262ba245 refactor: wip 2026-02-08 17:28:04 +05:30
Swapnil Nakade
df7c9e1339 chore: wip 2026-02-08 00:09:29 +05:30
Swapnil Nakade
ac5e52479f refactor: removing comment 2026-02-06 17:48:49 +05:30
Swapnil Nakade
de56477bbb Merge branch 'main' into feat/azure-integration 2026-02-06 17:45:40 +05:30
Swapnil Nakade
fddd8a27fa fix: aws connection url generation 2026-02-06 17:44:06 +05:30
Swapnil Nakade
2aa4f8e237 feat: adding logs 2026-02-06 11:57:23 +05:30
Swapnil Nakade
74006a214b ci: fixing lint ci issues 2026-02-06 04:01:39 +05:30
Swapnil Nakade
ed2cbacadc Merge branch 'main' into feat/azure-integration 2026-02-06 03:55:33 +05:30
Swapnil Nakade
3cbd529843 refactor: reverting 2026-02-06 03:51:29 +05:30
Swapnil Nakade
78b481e895 refactor: cloud integration API cleanup 2026-02-06 03:42:25 +05:30
Swapnil Nakade
215098ec0d refactor: sorting services list for consistency 2026-02-03 13:59:26 +05:30
Swapnil Nakade
5a4ef2e4ce refactor: code beautification 2026-02-03 01:16:58 +05:30
Swapnil Nakade
b1f33c4f7f refactor: updating service details api 2026-02-03 00:52:27 +05:30
Swapnil Nakade
713c84b1e4 refactor: updating cloud provider type 2026-02-03 00:10:18 +05:30
Swapnil Nakade
c3daf9e428 Merge branch 'main' into feat/azure-integration 2026-02-02 23:37:45 +05:30
Swapnil Nakade
70a908deb1 refactor: updating cloud-integration controller 2026-02-02 23:36:46 +05:30
Swapnil Nakade
cc9cdded3c refactor: updating cloud integration apis 2026-02-02 21:50:15 +05:30
Swapnil Nakade
77067cd614 feat: extending cloud integration apis 2026-01-29 12:20:19 +05:30
Swapnil Nakade
ab703d9a65 Merge branch 'main' into feat/azure-integration 2026-01-28 18:28:16 +05:30
Swapnil Nakade
611e8fbf9e refactor: updating azure cloud integrations api 2026-01-28 11:18:58 +05:30
131 changed files with 12037 additions and 10447 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
container_name: schema-migrator-async
command:
- async

View File

@@ -4,6 +4,7 @@ services:
container_name: signoz-otel-collector-dev
command:
- --config=/etc/otel-collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
environment:

View File

@@ -93,13 +93,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn install
- name: generate-api-clients
run: |
cd frontend && yarn generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,11 +209,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -232,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,11 +150,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
configs:
- source: otel-collector-config
target: /etc/otel-collector-config.yaml
@@ -175,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.111.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,12 +213,13 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -238,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -249,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.111.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,12 +144,13 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -165,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -177,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

View File

@@ -4355,8 +4355,6 @@ components:
type: string
unit:
type: string
required:
- name
type: object
Querybuildertypesv5QueryData:
properties:
@@ -4429,9 +4427,6 @@ components:
type: array
nullable: true
type: object
required:
- keys
- complete
type: object
TelemetrytypesGettableFieldValues:
properties:
@@ -4439,9 +4434,6 @@ components:
type: boolean
values:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldValues'
required:
- values
- complete
type: object
TelemetrytypesTelemetryFieldKey:
properties:
@@ -4457,8 +4449,6 @@ components:
type: string
unit:
type: string
required:
- name
type: object
TelemetrytypesTelemetryFieldValues:
properties:

View File

@@ -1,6 +1,7 @@
package api
import (
"log/slog"
"net/http"
"net/http/httputil"
"time"
@@ -13,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
@@ -21,6 +21,7 @@ import (
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
)
@@ -30,13 +31,14 @@ type APIHandlerOptions struct {
RulesManager *rules.Manager
UsageManager *usage.Manager
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
CloudIntegrationsRegistry map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
Gateway *httputil.ReverseProxy
GatewayUrl string
// Querier Influx Interval
FluxInterval time.Duration
GlobalConfig global.Config
Logger *slog.Logger // this is present in Signoz.Instrumentation but adding for quick access
}
type APIHandler struct {
@@ -50,7 +52,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
Reader: opts.DataConnector,
RuleManager: opts.RulesManager,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
CloudIntegrationsRegistry: opts.CloudIntegrationsRegistry,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
@@ -118,14 +120,12 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
}
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
router.HandleFunc(
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
).Methods(http.MethodGet)
}
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
@@ -13,20 +14,14 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
type CloudIntegrationConnectionParamsResponse struct {
IngestionUrl string `json:"ingestion_url,omitempty"`
IngestionKey string `json:"ingestion_key,omitempty"`
SigNozAPIUrl string `json:"signoz_api_url,omitempty"`
SigNozAPIKey string `json:"signoz_api_key,omitempty"`
}
// TODO: move this file with other cloud integration related code
func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
@@ -41,23 +36,21 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
cloudProvider := mux.Vars(r)["cloudProvider"]
if cloudProvider != "aws" {
RespondError(w, basemodel.BadRequest(fmt.Errorf(
"cloud provider not supported: %s", cloudProvider,
)), nil)
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't provision PAT for cloud integration:",
), nil)
apiKey, err := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
if err != nil {
render.Error(w, err)
return
}
result := CloudIntegrationConnectionParamsResponse{
result := integrationstypes.GettableCloudIntegrationConnectionParams{
SigNozAPIKey: apiKey,
}
@@ -71,16 +64,17 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
// Return the API Key (PAT) even if the rest of the params can not be deduced.
// Params not returned from here will be requested from the user via form inputs.
// This enables gracefully degraded but working experience even for non-cloud deployments.
zap.L().Info("ingestion params and signoz api url can not be deduced since no license was found")
ah.Respond(w, result)
ah.opts.Logger.InfoContext(
r.Context(),
"ingestion params and signoz api url can not be deduced since no license was found",
)
render.Success(w, http.StatusOK, result)
return
}
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url",
), nil)
signozApiUrl, err := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if err != nil {
render.Error(w, err)
return
}
@@ -89,48 +83,41 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
gatewayUrl := ah.opts.GatewayUrl
if len(gatewayUrl) > 0 {
ingestionKey, apiErr := getOrCreateCloudProviderIngestionKey(
ingestionKeyString, err := ah.getOrCreateCloudProviderIngestionKey(
r.Context(), gatewayUrl, license.Key, cloudProvider,
)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't get or create ingestion key",
), nil)
if err != nil {
render.Error(w, err)
return
}
result.IngestionKey = ingestionKey
result.IngestionKey = ingestionKeyString
} else {
zap.L().Info("ingestion key can't be deduced since no gateway url has been configured")
ah.opts.Logger.InfoContext(
r.Context(),
"ingestion key can't be deduced since no gateway url has been configured",
)
}
ah.Respond(w, result)
render.Success(w, http.StatusOK, result)
}
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
string, *basemodel.ApiError,
) {
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider valuer.String) (string, error) {
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
if apiErr != nil {
return "", apiErr
integrationUser, err := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
if err != nil {
return "", err
}
orgIdUUID, err := valuer.NewUUID(orgId)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't parse orgId: %w", err,
))
return "", err
}
allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
))
return "", err
}
for _, p := range allPats {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
@@ -138,9 +125,10 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
}
}
zap.L().Info(
ah.opts.Logger.InfoContext(
ctx,
"no PAT found for cloud integration, creating a new one",
zap.String("cloudProvider", cloudProvider),
slog.String("cloudProvider", cloudProvider.String()),
)
newPAT, err := types.NewStorableAPIKey(
@@ -150,68 +138,48 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
0,
)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
return "", err
}
err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
return "", err
}
return newPAT.Token, nil
}
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
ctx context.Context, orgId string, cloudProvider string,
) (*types.User, *basemodel.ApiError) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
// TODO: move this function out of handler and use proper module structure
func (ah *APIHandler) getOrCreateCloudIntegrationUser(ctx context.Context, orgId string, cloudProvider valuer.String) (*types.User, error) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider.String())
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
return nil, err
}
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
return nil, err
}
return cloudIntegrationUser, nil
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, *basemodel.ApiError,
) {
// TODO: remove this struct from here
type deploymentResponse struct {
Name string `json:"name"`
ClusterInfo struct {
Region struct {
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
}
// TODO: move this function out of handler and use proper module structure
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (string, error) {
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: error: %w", err,
))
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't query for deployment info: error")
}
resp := new(deploymentResponse)
resp := new(integrationstypes.GettableDeployment)
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal deployment info response")
}
regionDns := resp.ClusterInfo.Region.DNS
@@ -219,9 +187,11 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", basemodel.InternalError(fmt.Errorf(
return "", errors.WrapInternalf(
err,
errors.CodeInternal,
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
))
)
}
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
@@ -229,102 +199,85 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
return signozApiUrl, nil
}
type ingestionKey struct {
Name string `json:"name"`
Value string `json:"value"`
// other attributes from gateway response not included here since they are not being used.
}
type ingestionKeysSearchResponse struct {
Status string `json:"status"`
Data []ingestionKey `json:"data"`
Error string `json:"error"`
}
type createIngestionKeyResponse struct {
Status string `json:"status"`
Data ingestionKey `json:"data"`
Error string `json:"error"`
}
func getOrCreateCloudProviderIngestionKey(
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider string,
) (string, *basemodel.ApiError) {
func (ah *APIHandler) getOrCreateCloudProviderIngestionKey(
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider valuer.String,
) (string, error) {
cloudProviderKeyName := fmt.Sprintf("%s-integration", cloudProvider)
// see if the key already exists
searchResult, apiErr := requestGateway[ingestionKeysSearchResponse](
searchResult, err := requestGateway[integrationstypes.GettableIngestionKeysSearch](
ctx,
gatewayUrl,
licenseKey,
fmt.Sprintf("/v1/workspaces/me/keys/search?name=%s", cloudProviderKeyName),
nil,
ah.opts.Logger,
)
if apiErr != nil {
return "", basemodel.WrapApiError(
apiErr, "couldn't search for cloudprovider ingestion key",
)
if err != nil {
return "", err
}
if searchResult.Status != "success" {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't search for cloudprovider ingestion key: status: %s, error: %s",
return "", errors.NewInternalf(
errors.CodeInternal,
"couldn't search for cloud provider ingestion key: status: %s, error: %s",
searchResult.Status, searchResult.Error,
))
}
for _, k := range searchResult.Data {
if k.Name == cloudProviderKeyName {
if len(k.Value) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", basemodel.InternalError(fmt.Errorf(
"ingestion keys search response not as expected",
))
}
return k.Value, nil
}
}
zap.L().Info(
"no existing ingestion key found for cloud integration, creating a new one",
zap.String("cloudProvider", cloudProvider),
)
createKeyResult, apiErr := requestGateway[createIngestionKeyResponse](
ctx, gatewayUrl, licenseKey, "/v1/workspaces/me/keys",
map[string]any{
"name": cloudProviderKeyName,
"tags": []string{"integration", cloudProvider},
},
)
if apiErr != nil {
return "", basemodel.WrapApiError(
apiErr, "couldn't create cloudprovider ingestion key",
)
}
for _, k := range searchResult.Data {
if k.Name != cloudProviderKeyName {
continue
}
if len(k.Value) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", errors.NewInternalf(errors.CodeInternal, "ingestion keys search response not as expected")
}
return k.Value, nil
}
ah.opts.Logger.InfoContext(
ctx,
"no existing ingestion key found for cloud integration, creating a new one",
slog.String("cloudProvider", cloudProvider.String()),
)
createKeyResult, err := requestGateway[integrationstypes.GettableCreateIngestionKey](
ctx, gatewayUrl, licenseKey, "/v1/workspaces/me/keys",
map[string]any{
"name": cloudProviderKeyName,
"tags": []string{"integration", cloudProvider.String()},
},
ah.opts.Logger,
)
if err != nil {
return "", err
}
if createKeyResult.Status != "success" {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloudprovider ingestion key: status: %s, error: %s",
return "", errors.NewInternalf(
errors.CodeInternal,
"couldn't create cloud provider ingestion key: status: %s, error: %s",
createKeyResult.Status, createKeyResult.Error,
))
)
}
ingestionKey := createKeyResult.Data.Value
if len(ingestionKey) < 1 {
ingestionKeyString := createKeyResult.Data.Value
if len(ingestionKeyString) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", basemodel.InternalError(fmt.Errorf(
return "", errors.NewInternalf(errors.CodeInternal,
"ingestion key creation response not as expected",
))
)
}
return ingestionKey, nil
return ingestionKeyString, nil
}
func requestGateway[ResponseType any](
ctx context.Context, gatewayUrl string, licenseKey string, path string, payload any,
) (*ResponseType, *basemodel.ApiError) {
ctx context.Context, gatewayUrl, licenseKey, path string, payload any, logger *slog.Logger,
) (*ResponseType, error) {
baseUrl := strings.TrimSuffix(gatewayUrl, "/")
reqUrl := fmt.Sprintf("%s%s", baseUrl, path)
@@ -335,13 +288,12 @@ func requestGateway[ResponseType any](
"X-Consumer-Groups": "ns:default",
}
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload)
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload, logger)
}
func requestAndParseResponse[ResponseType any](
ctx context.Context, url string, headers map[string]string, payload any,
) (*ResponseType, *basemodel.ApiError) {
ctx context.Context, url string, headers map[string]string, payload any, logger *slog.Logger,
) (*ResponseType, error) {
reqMethod := http.MethodGet
var reqBody io.Reader
if payload != nil {
@@ -349,18 +301,14 @@ func requestAndParseResponse[ResponseType any](
bodyJson, err := json.Marshal(payload)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't serialize request payload to JSON: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't marshal payload")
}
reqBody = bytes.NewBuffer([]byte(bodyJson))
reqBody = bytes.NewBuffer(bodyJson)
}
req, err := http.NewRequestWithContext(ctx, reqMethod, url, reqBody)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't prepare request: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't create req")
}
for k, v := range headers {
@@ -373,23 +321,26 @@ func requestAndParseResponse[ResponseType any](
response, err := client.Do(req)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't make request: %w", err))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't make req")
}
defer response.Body.Close()
defer func() {
err = response.Body.Close()
if err != nil {
logger.ErrorContext(ctx, "couldn't close response body", "error", err)
}
}()
respBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't read response: %w", err))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read response body")
}
var resp ResponseType
err = json.Unmarshal(respBody, &resp)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal gateway response into %T", resp,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal response body")
}
return &resp, nil

View File

@@ -127,12 +127,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
}
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
if err != nil {
return nil, fmt.Errorf(
"couldn't create cloud provider integrations controller: %w", err,
)
}
cloudIntegrationsRegistry := cloudintegrations.NewCloudProviderRegistry(signoz.Instrumentation.Logger(), signoz.SQLStore, signoz.Querier)
// ingestion pipelines manager
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
@@ -167,12 +162,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
RulesManager: rm,
UsageManager: usageManager,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
CloudIntegrationsRegistry: cloudIntegrationsRegistry,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: config.Gateway.URL.String(),
GlobalConfig: config.Global,
Logger: signoz.Instrumentation.Logger(),
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)

View File

@@ -17,8 +17,6 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^@signozhq/icons$':
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
},
globals: {
extensionsToTreatAsEsm: ['.ts'],

View File

@@ -19,7 +19,7 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh && prettier --write src/api/generated && (eslint --fix src/api/generated || true)"
},
"engines": {
"node": ">=16.15.0"
@@ -52,7 +52,6 @@
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/resizable": "0.0.0",

View File

@@ -1,7 +1,6 @@
{
"SIGN_UP": "SigNoz | Sign Up",
"LOGIN": "SigNoz | Login",
"FORGOT_PASSWORD": "SigNoz | Forgot Password",
"HOME": "SigNoz | Home",
"SERVICE_METRICS": "SigNoz | Service Metrics",
"SERVICE_MAP": "SigNoz | Service Map",

View File

@@ -1,6 +1,5 @@
#!/bin/bash
echo "\n\n---\nRenamed tag files to index.ts...\n"
# Rename tag files to index.ts in services directories
# tags-split creates: services/tagName/tagName.ts -> rename to services/tagName/index.ts
find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
@@ -12,33 +11,4 @@ find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r
fi
done
echo "\n✅ Tag files renamed to index.ts"
# Format generated files
echo "\n\n---\nRunning prettier...\n"
if ! prettier --write src/api/generated; then
echo "Prettier formatting failed!"
exit 1
fi
echo "\n✅ Prettier formatting successful"
# Fix linting issues
echo "\n\n---\nRunning eslint...\n"
if ! yarn lint --fix --quiet src/api/generated; then
echo "ESLint check failed! Please fix linting errors before proceeding."
exit 1
fi
echo "\n✅ ESLint check successful"
# Check for type errors
echo "\n\n---\nChecking for type errors...\n"
if ! tsc --noEmit; then
echo "Type check failed! Please fix type errors before proceeding."
exit 1
fi
echo "\n✅ Type check successful"
echo "\n\n---\n ✅✅✅ API generation complete!"
echo "Tag files renamed to index.ts"

View File

@@ -194,10 +194,6 @@ export const Login = Loadable(
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
);
export const ForgotPassword = Loadable(
() => import(/* webpackChunkName: "ForgotPassword" */ 'pages/ForgotPassword'),
);
export const UnAuthorized = Loadable(
() => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'),
);

View File

@@ -17,7 +17,6 @@ import {
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
Home,
InfrastructureMonitoring,
InstalledIntegrations,
@@ -340,13 +339,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,

View File

@@ -1,222 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns field keys
* @summary Get field keys
*/
export const getFieldsKeys = (
params?: GetFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsKeys200>({
url: `/api/v1/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsKeysQueryKey = (params?: GetFieldsKeysParams) => {
return ['getFieldsKeys', ...(params ? [params] : [])] as const;
};
export const getGetFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsKeysQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsKeys>>> = ({
signal,
}) => getFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsKeys>>
>;
export type GetFieldsKeysQueryError = RenderErrorResponseDTO;
/**
* @summary Get field keys
*/
export function useGetFieldsKeys<
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field keys
*/
export const invalidateGetFieldsKeys = async (
queryClient: QueryClient,
params?: GetFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns field values
* @summary Get field values
*/
export const getFieldsValues = (
params?: GetFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsValues200>({
url: `/api/v1/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsValuesQueryKey = (params?: GetFieldsValuesParams) => {
return ['getFieldsValues', ...(params ? [params] : [])] as const;
};
export const getGetFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsValuesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsValues>>> = ({
signal,
}) => getFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsValues>>
>;
export type GetFieldsValuesQueryError = RenderErrorResponseDTO;
/**
* @summary Get field values
*/
export function useGetFieldsValues<
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsValuesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field values
*/
export const invalidateGetFieldsValues = async (
queryClient: QueryClient,
params?: GetFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -1049,7 +1049,7 @@ export interface Querybuildertypesv5OrderByKeyDTO {
/**
* @type string
*/
name: string;
name?: string;
/**
* @type string
*/
@@ -1141,79 +1141,6 @@ export interface RoletypesRoleDTO {
updatedAt?: Date;
}
/**
* @nullable
*/
export type TelemetrytypesGettableFieldKeysDTOKeys = {
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
} | null;
export interface TelemetrytypesGettableFieldKeysDTO {
/**
* @type boolean
*/
complete: boolean;
/**
* @type object
* @nullable true
*/
keys: TelemetrytypesGettableFieldKeysDTOKeys;
}
export interface TelemetrytypesGettableFieldValuesDTO {
/**
* @type boolean
*/
complete: boolean;
values: TelemetrytypesTelemetryFieldValuesDTO;
}
export interface TelemetrytypesTelemetryFieldKeyDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
fieldContext?: string;
/**
* @type string
*/
fieldDataType?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
signal?: string;
/**
* @type string
*/
unit?: string;
}
export interface TelemetrytypesTelemetryFieldValuesDTO {
/**
* @type array
*/
boolValues?: boolean[];
/**
* @type array
*/
numberValues?: number[];
/**
* @type array
*/
relatedValues?: string[];
/**
* @type array
*/
stringValues?: string[];
}
export interface TypesChangePasswordRequestDTO {
/**
* @type string
@@ -1661,132 +1588,6 @@ export type DeleteAuthDomainPathParameters = {
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type GetFieldsKeysParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
source?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type string
* @description undefined
*/
fieldContext?: string;
/**
* @type string
* @description undefined
*/
fieldDataType?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
searchText?: string;
};
export type GetFieldsKeys200 = {
data?: TelemetrytypesGettableFieldKeysDTO;
/**
* @type string
*/
status?: string;
};
export type GetFieldsValuesParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
source?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type string
* @description undefined
*/
fieldContext?: string;
/**
* @type string
* @description undefined
*/
fieldDataType?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
name?: string;
/**
* @type string
* @description undefined
*/
existingQuery?: string;
};
export type GetFieldsValues200 = {
data?: TelemetrytypesGettableFieldValuesDTO;
/**
* @type string
*/
status?: string;
};
export type GetResetPasswordTokenPathParameters = {
id: string;
};

View File

@@ -18,7 +18,6 @@ import '@signozhq/checkbox';
import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/resizable';

View File

@@ -648,13 +648,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
) : (
<Typography.Text
className="value-string"
ellipsis={{
tooltip: {
placement: 'top',
mouseEnterDelay: 0.2,
mouseLeaveDelay: 0,
},
}}
ellipsis={{ tooltip: { placement: 'top' } }}
>
{String(value)}
</Typography.Text>

View File

@@ -1,7 +1,6 @@
const ROUTES = {
SIGN_UP: '/signup',
LOGIN: '/login',
FORGOT_PASSWORD: '/forgot-password',
HOME: '/home',
SERVICE_METRICS: '/services/:servicename',
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table } from 'antd';
import { Spin, Table, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -14,13 +14,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { MoveUpRight } from 'lucide-react';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
@@ -127,67 +125,51 @@ function DomainList(): JSX.Element {
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Table
className={cx('api-monitoring-domain-list-table')}
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText:
isFetching || isLoading ? null : (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<div className="no-filtered-domains-message">
<div className="no-domain-title">
No External API calls detected with applied filters.
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
<div className="no-domain-subtitle">
Ensure all HTTP client spans are being sent with kind as{' '}
<span className="attribute">Client</span> and url set in{' '}
<span className="attribute">url.full</span> or{' '}
<span className="attribute">http.url</span> attribute.
</div>
<a
href={DOCLINKS.EXTERNAL_API_MONITORING}
target="_blank"
rel="noreferrer"
className="external-api-doc-link"
>
Learn how External API monitoring works in SigNoz{' '}
<MoveUpRight size={14} />
</a>
</div>
</div>
</div>
)}
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
<Table
className="api-monitoring-domain-list-table"
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
)}
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
{selectedDomainIndex !== -1 && (
<DomainDetails
domainData={formattedDataForTable[selectedDomainIndex]}

View File

@@ -180,59 +180,10 @@
.no-filtered-domains-message {
margin-top: 8px;
display: flex;
gap: 8px;
flex-direction: column;
.no-domain-title {
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.no-domain-subtitle {
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.attribute {
font-family: 'Space Mono';
}
}
.external-api-doc-link {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
}
}
.lightMode {
.no-filtered-domains-message-container {
.no-filtered-domains-message-content {
.no-filtered-domains-message {
.no-domain-title {
color: var(--text-ink-500);
}
.no-domain-subtitle {
color: var(--text-ink-400);
.attribute {
font-family: 'Space Mono';
}
}
}
}
}
.api-monitoring-domain-list-table {
.ant-table {
.ant-table-thead > tr > th {

View File

@@ -1,104 +0,0 @@
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function ChartWrapper({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
showTooltip = true,
canPinTooltip = false,
syncMode,
syncKey,
onDestroy = noop,
children,
layoutChildren,
renderTooltip,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[config, legendConfig.position],
);
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (renderTooltip) {
return renderTooltip(args);
}
return null;
},
[renderTooltip],
);
return (
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{showTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={renderTooltipCallback}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -1,46 +1,104 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
import {
TimeSeriesTooltipProps,
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import _noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { TimeSeriesChartProps } from '../types';
import { ChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const content = buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: rest.yAxisUnit ?? '',
decimalPrecision: rest.decimalPrecision,
});
const tooltipProps: TimeSeriesTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
content,
};
return <TimeSeriesTooltip {...tooltipProps} />;
export default function TimeSeries({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
disableTooltip = false,
canPinTooltip = false,
timezone,
yAxisUnit,
decimalPrecision,
syncMode,
syncKey,
onDestroy = _noop,
children,
layoutChildren,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[config, legendConfig.position],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
{children}
</ChartWrapper>
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{!disableTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={(props: TooltipRenderArgs): React.ReactNode => (
<Tooltip
{...props}
timezone={timezone}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
/>
)}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -1,39 +1,29 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { LegendConfig } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
height: number;
showTooltip?: boolean;
disableTooltip?: boolean;
timezone: string;
syncMode?: DashboardCursorSync;
syncKey?: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {
interface TimeSeriesChartProps extends BaseChartProps {
config: UPlotConfigBuilder;
legendConfig: LegendConfig;
data: uPlot.AlignedData;
syncMode?: DashboardCursorSync;
syncKey?: string;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {
legendConfig: LegendConfig;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
legendConfig: LegendConfig;
isStackedBarChart?: boolean;
}
export type ChartProps = TimeSeriesChartProps | BarChartProps;
export type ChartProps = TimeSeriesChartProps;

View File

@@ -6,6 +6,7 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LineInterpolation } from 'lib/uPlotV2/config/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
@@ -72,28 +73,55 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
}, [queryResponse?.data?.payload]);
const config = useMemo(() => {
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
return prepareUPlotConfig({
widget,
isDarkMode,
currentQuery: widget.query,
onClick: clickHandlerWithContextMenu,
onDragSelect,
widgetId: widget.id || '',
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
timezone,
panelMode,
tzDate,
minTimeScale: minTimeScale,
maxTimeScale: maxTimeScale,
isLogScale: widget?.isLogScale ?? false,
thresholds: {
scaleKey: 'y',
thresholds: (widget.thresholds || []).map((threshold) => ({
thresholdValue: threshold.thresholdValue ?? 0,
thresholdColor: threshold.thresholdColor,
thresholdUnit: threshold.thresholdUnit,
thresholdLabel: threshold.thresholdLabel,
})),
yAxisUnit: widget.yAxisUnit,
},
yAxisUnit: widget.yAxisUnit || '',
softMin: widget.softMin === undefined ? null : widget.softMin,
softMax: widget.softMax === undefined ? null : widget.softMax,
spanGaps: false,
colorMapping: widget.customLegendColors ?? {},
lineInterpolation: LineInterpolation.Spline,
isDarkMode,
onClick: clickHandlerWithContextMenu,
onDragSelect,
currentQuery: widget.query,
panelMode,
});
}, [
widget,
widget.id,
maxTimeScale,
minTimeScale,
timezone.value,
widget.customLegendColors,
widget.isLogScale,
widget.softMax,
widget.softMin,
isDarkMode,
queryResponse?.data?.payload,
widget.query,
widget.thresholds,
widget.yAxisUnit,
panelMode,
clickHandlerWithContextMenu,
onDragSelect,
queryResponse?.data?.payload,
panelMode,
minTimeScale,
maxTimeScale,
timezone,
]);
const layoutChildren = useMemo(() => {

View File

@@ -1,4 +1,3 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fillMissingXAxisTimestamps,
@@ -6,20 +5,23 @@ import {
} from 'container/DashboardContainer/visualization/panels/utils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
DrawStyle,
LineInterpolation,
LineStyle,
SelectionPreferencesSource,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { Widgets } from 'types/api/dashboard/getAll';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export const prepareChartData = (
apiResponse: MetricRangePayloadProps,
@@ -32,39 +34,112 @@ export const prepareChartData = (
};
export const prepareUPlotConfig = ({
widget,
isDarkMode,
currentQuery,
onClick,
onDragSelect,
widgetId,
apiResponse,
timezone,
panelMode,
tzDate,
minTimeScale,
maxTimeScale,
isLogScale,
thresholds,
softMin,
softMax,
spanGaps,
colorMapping,
lineInterpolation,
isDarkMode,
currentQuery,
onDragSelect,
onClick,
yAxisUnit,
panelMode,
}: {
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
widgetId: string;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;
tzDate: uPlot.LocalDateFromUnix;
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
isLogScale: boolean;
softMin: number | null;
softMax: number | null;
spanGaps: boolean;
colorMapping: Record<string, string>;
lineInterpolation: LineInterpolation;
isDarkMode: boolean;
thresholds: ThresholdsDrawHookOptions;
currentQuery: Query;
yAxisUnit: string;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
panelMode: PanelMode;
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder => {
const builder = buildBaseConfig({
widget,
isDarkMode,
onClick,
const builder = new UPlotConfigBuilder({
onDragSelect,
apiResponse,
timezone,
panelMode,
widgetId,
tzDate,
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: [
PanelMode.DASHBOARD_VIEW,
PanelMode.STANDALONE_VIEW,
].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY,
});
// X scale time axis
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
// Y scale value axis, driven primarily by softMin/softMax and data
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin: softMin ?? undefined,
softMax: softMax ?? undefined,
thresholds,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addThresholds(thresholds);
if (typeof onClick === 'function') {
builder.addPlugin(
onClickPlugin({
onClick,
apiResponse,
}),
);
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale: false,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale: false,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
minTimeScale,
maxTimeScale,
});
apiResponse.data?.result?.forEach((series) => {
@@ -82,16 +157,14 @@ export const prepareUPlotConfig = ({
scaleKey: 'y',
drawStyle: DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
colorMapping,
spanGaps,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
lineInterpolation,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
});
return builder;
};

View File

@@ -1,127 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../types';
export interface BaseConfigBuilderProps {
widget: Widgets;
apiResponse: MetricRangePayloadProps;
isDarkMode: boolean;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
timezone: Timezone;
panelMode: PanelMode;
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildBaseConfig({
widget,
isDarkMode,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
panelType,
minTimeScale,
maxTimeScale,
}: BaseConfigBuilderProps): UPlotConfigBuilder {
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
onDragSelect,
widgetId: widget.id,
tzDate,
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: [
PanelMode.DASHBOARD_VIEW,
PanelMode.STANDALONE_VIEW,
].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY,
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: (widget.thresholds || []).map((threshold) => ({
thresholdValue: threshold.thresholdValue ?? 0,
thresholdColor: threshold.thresholdColor,
thresholdUnit: threshold.thresholdUnit,
thresholdLabel: threshold.thresholdLabel,
})),
yAxisUnit: widget.yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
// Y scale value axis, driven primarily by softMin/softMax and data
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin: widget.softMin ?? undefined,
softMax: widget.softMax ?? undefined,
// thresholds,
logBase: widget.isLogScale ? 10 : undefined,
distribution: widget.isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(
onClickPlugin({
onClick,
apiResponse,
}),
);
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale: widget.isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale: widget.isLogScale,
yAxisUnit: widget.yAxisUnit,
panelType,
});
return builder;
}

View File

@@ -1,93 +0,0 @@
.forgot-password-title {
font-family: var(--label-large-600-font-family);
font-size: var(--label-large-600-font-size);
font-weight: var(--label-large-600-font-weight);
letter-spacing: var(--label-large-600-letter-spacing);
line-height: 1.45;
color: var(--l1-foreground);
margin: 0;
}
.forgot-password-description {
font-family: var(--paragraph-base-400-font-family);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l2-foreground);
margin: 0;
text-align: center;
max-width: 317px;
}
.forgot-password-form {
width: 100%;
// Label styling
.forgot-password-label {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin-bottom: 12px;
display: block;
.lightMode & {
color: var(--text-ink-500);
}
}
// Parent container for fields
.forgot-password-field {
width: 100%;
display: flex;
flex-direction: column;
}
&.ant-form {
display: flex;
flex-direction: column;
align-items: flex-start;
.ant-form-item {
margin-bottom: 0px;
width: 100%;
}
}
}
.forgot-password-actions {
display: flex;
gap: 12px;
width: 100%;
> .forgot-password-back-button,
> .login-submit-btn {
flex: 1 1 0%;
}
}
.forgot-password-back-button {
height: 32px;
padding: 10px 16px;
border-radius: 2px;
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--l3-background);
border: 1px solid var(--l3-border);
color: var(--l1-foreground);
&:hover:not(:disabled) {
background: var(--l3-border);
border-color: var(--l3-border);
opacity: 0.9;
}
}

View File

@@ -1,41 +0,0 @@
import { Button } from '@signozhq/button';
import { ArrowLeft, Mail } from '@signozhq/icons';
interface SuccessScreenProps {
onBackToLogin: () => void;
}
function SuccessScreen({ onBackToLogin }: SuccessScreenProps): JSX.Element {
return (
<div className="login-form-container">
<div className="forgot-password-form">
<div className="login-form-header">
<div className="login-form-emoji">
<Mail size={32} />
</div>
<h4 className="forgot-password-title">Check your email</h4>
<p className="forgot-password-description">
We&apos;ve sent a password reset link to your email. Please check your
inbox and follow the instructions to reset your password.
</p>
</div>
<div className="login-form-actions forgot-password-actions">
<Button
variant="solid"
color="primary"
type="button"
data-testid="back-to-login"
className="login-submit-btn"
onClick={onBackToLogin}
prefixIcon={<ArrowLeft size={12} />}
>
Back to login
</Button>
</div>
</div>
</div>
);
}
export default SuccessScreen;

View File

@@ -1,402 +0,0 @@
import ROUTES from 'constants/routes';
import history from 'lib/history';
import {
createErrorResponse,
handleInternalServerError,
rest,
server,
} from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
import ForgotPassword, { ForgotPasswordRouteState } from '../index';
// Mock dependencies
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
const FORGOT_PASSWORD_ENDPOINT = '*/api/v2/factor_password/forgot';
// Mock data
const mockSingleOrg: OrgSessionContext[] = [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
];
const mockMultipleOrgs: OrgSessionContext[] = [
{
id: 'org-1',
name: 'Organization One',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: 'Organization Two',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
];
const TEST_EMAIL = 'jest.test@signoz.io';
const defaultProps: ForgotPasswordRouteState = {
email: TEST_EMAIL,
orgs: mockSingleOrg,
};
const multiOrgProps: ForgotPasswordRouteState = {
email: TEST_EMAIL,
orgs: mockMultipleOrgs,
};
describe('ForgotPassword Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders forgot password form with all required elements', () => {
render(<ForgotPassword {...defaultProps} />);
expect(screen.getByText(/forgot your password\?/i)).toBeInTheDocument();
expect(
screen.getByText(/send a reset link to your inbox/i),
).toBeInTheDocument();
expect(screen.getByTestId('email')).toBeInTheDocument();
expect(screen.getByTestId('forgot-password-submit')).toBeInTheDocument();
expect(screen.getByTestId('forgot-password-back')).toBeInTheDocument();
});
it('pre-fills email from props', () => {
render(<ForgotPassword {...defaultProps} />);
const emailInput = screen.getByTestId('email');
expect(emailInput).toHaveValue(TEST_EMAIL);
});
it('disables email input field', () => {
render(<ForgotPassword {...defaultProps} />);
const emailInput = screen.getByTestId('email');
expect(emailInput).toBeDisabled();
});
it('does not show organization dropdown for single org', () => {
render(<ForgotPassword {...defaultProps} />);
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
expect(screen.queryByText('Organization Name')).not.toBeInTheDocument();
});
it('enables submit button when email is provided with single org', () => {
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).not.toBeDisabled();
});
});
describe('Multiple Organizations', () => {
it('shows organization dropdown when multiple orgs exist', () => {
render(<ForgotPassword {...multiOrgProps} />);
expect(screen.getByTestId('orgId')).toBeInTheDocument();
expect(screen.getByText('Organization Name')).toBeInTheDocument();
});
it('disables submit button when org is not selected', () => {
const propsWithoutOrgId: ForgotPasswordRouteState = {
email: TEST_EMAIL,
orgs: mockMultipleOrgs,
};
render(<ForgotPassword {...propsWithoutOrgId} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toBeDisabled();
});
it('enables submit button after selecting an organization', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ForgotPassword {...multiOrgProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toBeDisabled();
// Click on the dropdown to reveal the options
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText('Organization One'));
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
it('pre-selects organization when orgId is provided', () => {
const propsWithOrgId: ForgotPasswordRouteState = {
email: TEST_EMAIL,
orgId: 'org-1',
orgs: mockMultipleOrgs,
};
render(<ForgotPassword {...propsWithOrgId} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).not.toBeDisabled();
});
});
describe('Form Submission - Success', () => {
it('successfully submits forgot password request and shows success screen', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
expect(
screen.getByText(/we've sent a password reset link/i),
).toBeInTheDocument();
});
it('shows back to login button on success screen', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
});
it('redirects to login when clicking back to login on success screen', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
const backToLoginButton = screen.getByTestId('back-to-login');
await user.click(backToLoginButton);
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
describe('Form Submission - Error Handling', () => {
it('displays error message when forgot password API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(
FORGOT_PASSWORD_ENDPOINT,
createErrorResponse(400, 'USER_NOT_FOUND', 'User not found'),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
});
it('displays error message when API returns server error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(rest.post(FORGOT_PASSWORD_ENDPOINT, handleInternalServerError));
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(
await screen.findByText(/internal server error occurred/i),
).toBeInTheDocument();
});
it('clears error message on new submission attempt', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let requestCount = 0;
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) => {
requestCount += 1;
if (requestCount === 1) {
return res(
ctx.status(400),
ctx.json({
error: {
code: 'USER_NOT_FOUND',
message: 'User not found',
},
}),
);
}
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
// Click submit again
await user.click(submitButton);
await waitFor(() => {
expect(screen.queryByText(/user not found/i)).not.toBeInTheDocument();
});
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('redirects to login when clicking back button on form', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ForgotPassword {...defaultProps} />);
const backButton = screen.getByTestId('forgot-password-back');
await user.click(backButton);
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
describe('Loading States', () => {
it('shows loading state during API call', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
// Button should show loading state
expect(await screen.findByText(/sending\.\.\./i)).toBeInTheDocument();
});
it('disables submit button during loading', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
),
);
render(<ForgotPassword {...defaultProps} />);
const submitButton = screen.getByTestId('forgot-password-submit');
await user.click(submitButton);
await waitFor(() => {
expect(submitButton).toBeDisabled();
});
});
});
describe('Edge Cases', () => {
it('handles empty email gracefully', () => {
const propsWithEmptyEmail: ForgotPasswordRouteState = {
email: '',
orgs: mockSingleOrg,
};
render(<ForgotPassword {...propsWithEmptyEmail} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toBeDisabled();
});
it('handles whitespace-only email', () => {
const propsWithWhitespaceEmail: ForgotPasswordRouteState = {
email: ' ',
orgs: mockSingleOrg,
};
render(<ForgotPassword {...propsWithWhitespaceEmail} />);
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toBeDisabled();
});
it('handles empty orgs array by disabling submission', () => {
const propsWithNoOrgs: ForgotPasswordRouteState = {
email: TEST_EMAIL,
orgs: [],
};
render(<ForgotPassword {...propsWithNoOrgs} />);
// Should not show org dropdown
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
// Submit should be disabled because no orgId can be determined
const submitButton = screen.getByTestId('forgot-password-submit');
expect(submitButton).toBeDisabled();
});
});
});

View File

@@ -1,217 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Button } from '@signozhq/button';
import { ArrowLeft, ArrowRight } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Select } from 'antd';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useForgotPassword } from 'api/generated/services/users';
import { AxiosError } from 'axios';
import AuthError from 'components/AuthError/AuthError';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
import SuccessScreen from './SuccessScreen';
import './ForgotPassword.styles.scss';
import 'container/Login/Login.styles.scss';
type FormValues = {
email: string;
orgId: string;
};
export type ForgotPasswordRouteState = {
email: string;
orgId?: string;
orgs: OrgSessionContext[];
};
function ForgotPassword({
email,
orgId,
orgs,
}: ForgotPasswordRouteState): JSX.Element {
const [form] = Form.useForm<FormValues>();
const {
mutate: forgotPasswordMutate,
isLoading,
isSuccess,
error: mutationError,
} = useForgotPassword();
const errorMessage = useMemo(() => {
if (!mutationError) {
return undefined;
}
try {
ErrorResponseHandlerV2(mutationError as AxiosError<ErrorV2Resp>);
} catch (apiError) {
return apiError as APIError;
}
}, [mutationError]);
const initialOrgId = useMemo((): string | undefined => {
if (orgId) {
return orgId;
}
if (orgs.length === 1) {
return orgs[0]?.id;
}
return undefined;
}, [orgId, orgs]);
const watchedEmail = Form.useWatch('email', form);
const selectedOrgId = Form.useWatch('orgId', form);
useEffect(() => {
form.setFieldsValue({
email,
orgId: initialOrgId,
});
}, [email, form, initialOrgId]);
const hasMultipleOrgs = orgs.length > 1;
const isSubmitEnabled = useMemo((): boolean => {
if (isLoading) {
return false;
}
if (!watchedEmail?.trim()) {
return false;
}
// Ensure we have an orgId (either selected from dropdown or the initial one)
const currentOrgId = hasMultipleOrgs ? selectedOrgId : initialOrgId;
return Boolean(currentOrgId);
}, [watchedEmail, selectedOrgId, isLoading, initialOrgId, hasMultipleOrgs]);
const handleSubmit = useCallback((): void => {
const values = form.getFieldsValue();
const currentOrgId = hasMultipleOrgs ? values.orgId : initialOrgId;
if (!currentOrgId) {
return;
}
// Call the forgot password API
forgotPasswordMutate({
data: {
email: values.email,
orgId: currentOrgId,
frontendBaseURL: window.location.origin,
},
});
}, [form, forgotPasswordMutate, initialOrgId, hasMultipleOrgs]);
const handleBackToLogin = useCallback((): void => {
history.push(ROUTES.LOGIN);
}, []);
// Success screen
if (isSuccess) {
return <SuccessScreen onBackToLogin={handleBackToLogin} />;
}
// Form screen
return (
<div className="login-form-container">
<Form
form={form}
onFinish={handleSubmit}
className="forgot-password-form"
initialValues={{
email,
orgId: initialOrgId,
}}
>
<div className="login-form-header">
<div className="login-form-emoji">
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
</div>
<h4 className="forgot-password-title">Forgot your password?</h4>
<p className="forgot-password-description">
Send a reset link to your inbox and get back to monitoring.
</p>
</div>
<div className="login-form-card">
<div className="forgot-password-field">
<label className="forgot-password-label" htmlFor="forgotPasswordEmail">
Email address
</label>
<Form.Item name="email">
<Input
type="email"
id="forgotPasswordEmail"
data-testid="email"
required
disabled
className="login-form-input"
/>
</Form.Item>
</div>
{hasMultipleOrgs && (
<div className="forgot-password-field">
<label className="forgot-password-label" htmlFor="orgId">
Organization Name
</label>
<Form.Item
name="orgId"
rules={[{ required: true, message: 'Please select your organization' }]}
>
<Select
id="orgId"
data-testid="orgId"
className="login-form-input login-form-select-no-border"
placeholder="Select your organization"
options={orgs.map((org) => ({
value: org.id,
label: org.name || 'default',
}))}
/>
</Form.Item>
</div>
)}
</div>
{errorMessage && <AuthError error={errorMessage} />}
<div className="login-form-actions forgot-password-actions">
<Button
variant="solid"
type="button"
data-testid="forgot-password-back"
className="forgot-password-back-button"
onClick={handleBackToLogin}
prefixIcon={<ArrowLeft size={12} />}
>
Back to login
</Button>
<Button
disabled={!isSubmitEnabled}
loading={isLoading}
variant="solid"
color="primary"
type="submit"
data-testid="forgot-password-submit"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
>
{isLoading ? 'Sending...' : 'Send reset link'}
</Button>
</div>
</Form>
</div>
);
}
export default ForgotPassword;

View File

@@ -2,6 +2,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { useHistory } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
@@ -26,20 +27,12 @@ import {
} from 'antd';
import { NotificationInstance } from 'antd/es/notification/interface';
import { CollapseProps } from 'antd/lib';
import {
useCreateIngestionKey,
useCreateIngestionKeyLimit,
useDeleteIngestionKey,
useDeleteIngestionKeyLimit,
useGetIngestionKeys,
useSearchIngestionKeys,
useUpdateIngestionKey,
useUpdateIngestionKeyLimit,
} from 'api/generated/services/gateway';
import {
GatewaytypesIngestionKeyDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import createIngestionKeyApi from 'api/IngestionKeys/createIngestionKey';
import deleteIngestionKey from 'api/IngestionKeys/deleteIngestionKey';
import createLimitForIngestionKeyApi from 'api/IngestionKeys/limits/createLimitsForKey';
import deleteLimitsForIngestionKey from 'api/IngestionKeys/limits/deleteLimitsForIngestionKey';
import updateLimitForIngestionKeyApi from 'api/IngestionKeys/limits/updateLimitsForIngestionKey';
import updateIngestionKey from 'api/IngestionKeys/updateIngestionKey';
import { AxiosError } from 'axios';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import Tags from 'components/Tags/Tags';
@@ -51,6 +44,7 @@ import ROUTES from 'constants/routes';
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
import dayjs from 'dayjs';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
@@ -72,12 +66,16 @@ import {
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { ErrorResponse } from 'types/api';
import {
AddLimitProps,
LimitProps,
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import {
IngestionKeyProps,
PaginationProps,
} from 'types/api/ingestionKeys/types';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -88,10 +86,6 @@ const { Option } = Select;
const BYTES = 1073741824;
const INITIAL_PAGE_SIZE = 10;
const SEARCH_PAGE_SIZE = 100;
const FIRST_PAGE = 1;
const COUNT_MULTIPLIER = {
thousand: 1000,
million: 1000000,
@@ -117,8 +111,6 @@ export const showErrorNotification = (
): void => {
notifications.error({
message: err.message || SOMETHING_WENT_WRONG,
description: (err as AxiosError<RenderErrorResponseDTO>).response?.data?.error
?.message,
});
};
@@ -171,20 +163,15 @@ function MultiIngestionSettings(): JSX.Element {
const [updatedTags, setUpdatedTags] = useState<string[]>([]);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isEditAddLimitOpen, setIsEditAddLimitOpen] = useState(false);
const [
activeAPIKey,
setActiveAPIKey,
] = useState<GatewaytypesIngestionKeyDTO | null>(null);
const [activeAPIKey, setActiveAPIKey] = useState<IngestionKeyProps | null>();
const [activeSignal, setActiveSignal] = useState<LimitProps | null>(null);
const [searchValue, setSearchValue] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [dataSource, setDataSource] = useState<GatewaytypesIngestionKeyDTO[]>(
[],
);
const [dataSource, setDataSource] = useState<IngestionKeyProps[]>([]);
const [paginationParams, setPaginationParams] = useState<PaginationProps>({
page: FIRST_PAGE,
per_page: INITIAL_PAGE_SIZE,
page: 1,
per_page: 10,
});
const [totalIngestionKeys, setTotalIngestionKeys] = useState(0);
@@ -199,7 +186,7 @@ function MultiIngestionSettings(): JSX.Element {
const [
createLimitForIngestionKeyError,
setCreateLimitForIngestionKeyError,
] = useState<string | null>(null);
] = useState<ErrorResponse | null>(null);
const [
hasUpdateLimitForIngestionKeyError,
@@ -209,7 +196,7 @@ function MultiIngestionSettings(): JSX.Element {
const [
updateLimitForIngestionKeyError,
setUpdateLimitForIngestionKeyError,
] = useState<string | null>(null);
] = useState<ErrorResponse | null>(null);
const { t } = useTranslation(['ingestionKeys']);
@@ -229,11 +216,7 @@ function MultiIngestionSettings(): JSX.Element {
handleFormReset();
};
const showDeleteModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
const showDeleteModal = (apiKey: IngestionKeyProps): void => {
setActiveAPIKey(apiKey);
setIsDeleteModalOpen(true);
};
@@ -250,11 +233,7 @@ function MultiIngestionSettings(): JSX.Element {
setIsAddModalOpen(false);
};
const showEditModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
const showEditModal = (apiKey: IngestionKeyProps): void => {
setActiveAPIKey(apiKey);
handleFormReset();
setUpdatedTags(apiKey.tags || []);
@@ -269,10 +248,6 @@ function MultiIngestionSettings(): JSX.Element {
};
const showAddModal = (): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
setUpdatedTags([]);
setActiveAPIKey(null);
setIsAddModalOpen(true);
@@ -283,62 +258,27 @@ function MultiIngestionSettings(): JSX.Element {
setActiveSignal(null);
};
// Use search API when searchText is present, otherwise use normal get API
const isSearching = searchText.length > 0;
const {
data: ingestionKeysData,
isLoading: isLoadingGet,
isRefetching: isRefetchingGet,
refetch: refetchGetAPIKeys,
error: getError,
isError: isGetError,
} = useGetIngestionKeys(
{
...paginationParams,
},
{
query: {
enabled: !isSearching,
},
},
);
const {
data: searchIngestionKeysData,
isLoading: isLoadingSearch,
isRefetching: isRefetchingSearch,
refetch: refetchSearchAPIKeys,
error: searchError,
isError: isSearchError,
} = useSearchIngestionKeys(
{
page: FIRST_PAGE,
per_page: SEARCH_PAGE_SIZE,
name: searchText,
},
{
query: {
enabled: isSearching,
},
},
);
// Use the appropriate data based on which API is active
const ingestionKeys = isSearching
? searchIngestionKeysData
: ingestionKeysData;
const isLoading = isSearching ? isLoadingSearch : isLoadingGet;
const isRefetching = isSearching ? isRefetchingSearch : isRefetchingGet;
const refetchAPIKeys = isSearching ? refetchSearchAPIKeys : refetchGetAPIKeys;
const error = isSearching ? searchError : getError;
const isError = isSearching ? isSearchError : isGetError;
data: IngestionKeys,
isLoading,
isRefetching,
refetch: refetchAPIKeys,
error,
isError,
} = useGetAllIngestionsKeys({
search: searchText,
...paginationParams,
});
useEffect(() => {
setDataSource(ingestionKeys?.data.data?.keys || []);
setTotalIngestionKeys(ingestionKeys?.data?.data?._pagination?.total || 0);
setActiveAPIKey(IngestionKeys?.data.data[0]);
}, [IngestionKeys]);
useEffect(() => {
setDataSource(IngestionKeys?.data.data || []);
setTotalIngestionKeys(IngestionKeys?.data?._pagination?.total || 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ingestionKeys?.data?.data]);
}, [IngestionKeys?.data?.data]);
useEffect(() => {
if (isError) {
@@ -357,7 +297,6 @@ function MultiIngestionSettings(): JSX.Element {
const clearSearch = (): void => {
setSearchValue('');
setSearchText('');
};
const {
@@ -370,54 +309,101 @@ function MultiIngestionSettings(): JSX.Element {
const {
mutate: createIngestionKey,
isLoading: isLoadingCreateAPIKey,
} = useCreateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(createIngestionKeyApi, {
onSuccess: (data) => {
setActiveAPIKey(data.payload);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
});
const {
mutate: updateAPIKey,
isLoading: isLoadingUpdateAPIKey,
} = useUpdateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
updateIngestionKey,
{
onSuccess: () => {
refetchAPIKeys();
setIsEditModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const {
mutate: deleteAPIKey,
isLoading: isDeleteingAPIKey,
} = useDeleteIngestionKey<AxiosError<RenderErrorResponseDTO>>();
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
deleteIngestionKey,
{
onSuccess: () => {
refetchAPIKeys();
setIsDeleteModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const {
mutate: createLimitForIngestionKey,
isLoading: isLoadingLimitForKey,
} = useCreateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(createLimitForIngestionKeyApi, {
onSuccess: () => {
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasCreateLimitForIngestionKeyError(false);
},
onError: (error: ErrorResponse) => {
setHasCreateLimitForIngestionKeyError(true);
setCreateLimitForIngestionKeyError(error);
},
});
const {
mutate: updateLimitForIngestionKey,
isLoading: isLoadingUpdatedLimitForKey,
} = useUpdateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(updateLimitForIngestionKeyApi, {
onSuccess: () => {
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasUpdateLimitForIngestionKeyError(false);
},
onError: (error: ErrorResponse) => {
setHasUpdateLimitForIngestionKeyError(true);
setUpdateLimitForIngestionKeyError(error);
},
});
const {
mutate: deleteLimitForKey,
isLoading: isDeletingLimit,
} = useDeleteIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
const { mutate: deleteLimitForKey, isLoading: isDeletingLimit } = useMutation(
deleteLimitsForIngestionKey,
{
onSuccess: () => {
setIsDeleteModalOpen(false);
setIsDeleteLimitModalOpen(false);
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const onDeleteHandler = (): void => {
clearSearch();
if (activeAPIKey && activeAPIKey.id) {
deleteAPIKey(
{
pathParams: { keyId: activeAPIKey.id },
},
{
onSuccess: () => {
notifications.success({
message: 'Ingestion key deleted successfully',
});
refetchAPIKeys();
setIsDeleteModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
if (activeAPIKey) {
deleteAPIKey(activeAPIKey.id);
}
};
@@ -425,31 +411,15 @@ function MultiIngestionSettings(): JSX.Element {
editForm
.validateFields()
.then((values) => {
if (activeAPIKey && activeAPIKey.id) {
updateAPIKey(
{
pathParams: { keyId: activeAPIKey.id },
data: {
name: values.name,
tags: updatedTags,
expires_at: new Date(
dayjs(values.expires_at).endOf('day').toISOString(),
),
},
if (activeAPIKey) {
updateAPIKey({
id: activeAPIKey.id,
data: {
name: values.name,
tags: updatedTags,
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
},
{
onSuccess: () => {
notifications.success({
message: 'Ingestion key updated successfully',
});
refetchAPIKeys();
setIsEditModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
});
}
})
.catch((errorInfo) => {
@@ -465,30 +435,10 @@ function MultiIngestionSettings(): JSX.Element {
const requestPayload = {
name: values.name,
tags: updatedTags,
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
};
createIngestionKey(
{
data: requestPayload,
},
{
onSuccess: (_data) => {
notifications.success({
message: 'Ingestion key created successfully',
});
// The new API returns GatewaytypesGettableCreatedIngestionKeyDTO with only id and value
// We rely on refetchAPIKeys to get the full key object
setActiveAPIKey(null);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
createIngestionKey(requestPayload);
}
})
.catch((errorInfo) => {
@@ -515,7 +465,7 @@ function MultiIngestionSettings(): JSX.Element {
formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.UTC_MONTH_COMPACT);
const showDeleteLimitModal = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
limit: LimitProps,
): void => {
setActiveAPIKey(APIKey);
@@ -539,17 +489,9 @@ function MultiIngestionSettings(): JSX.Element {
/* eslint-disable sonarjs/cognitive-complexity */
const handleAddLimit = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signalName: string,
): void => {
if (!APIKey.id) {
notifications.error({
message: 'Invalid ingestion key',
description: 'Cannot create limit for ingestion key without a valid ID',
});
return;
}
const {
dailyLimit,
secondsLimit,
@@ -634,49 +576,13 @@ function MultiIngestionSettings(): JSX.Element {
return;
}
createLimitForIngestionKey(
{
pathParams: { keyId: payload.keyID },
data: {
signal: payload.signal,
config: payload.config,
},
},
{
onSuccess: () => {
notifications.success({
message: 'Limit created successfully',
});
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasCreateLimitForIngestionKeyError(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
setHasCreateLimitForIngestionKeyError(true);
setCreateLimitForIngestionKeyError(
error.response?.data?.error?.message || 'Failed to create limit',
);
},
},
);
createLimitForIngestionKey(payload);
};
const handleUpdateLimit = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
if (!signal.id) {
notifications.error({
message: 'Invalid limit',
description: 'Cannot update limit without a valid ID',
});
return;
}
const {
dailyLimit,
secondsLimit,
@@ -738,34 +644,7 @@ function MultiIngestionSettings(): JSX.Element {
}
}
updateLimitForIngestionKey(
{
pathParams: { limitId: payload.limitID },
data: {
config: payload.config,
},
},
{
onSuccess: () => {
notifications.success({
message: 'Limit updated successfully',
});
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasUpdateLimitForIngestionKeyError(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
setHasUpdateLimitForIngestionKeyError(true);
setUpdateLimitForIngestionKeyError(
error.response?.data?.error?.message || 'Failed to update limit',
);
},
},
);
updateLimitForIngestionKey(payload);
};
/* eslint-enable sonarjs/cognitive-complexity */
@@ -777,7 +656,7 @@ function MultiIngestionSettings(): JSX.Element {
};
const enableEditLimitMode = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
const dayCount = signal?.config?.day?.count;
@@ -786,11 +665,6 @@ function MultiIngestionSettings(): JSX.Element {
const dayCountConverted = countToUnit(dayCount || 0);
const secondCountConverted = countToUnit(secondCount || 0);
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
setActiveAPIKey(APIKey);
setActiveSignal({
...signal,
@@ -829,31 +703,14 @@ function MultiIngestionSettings(): JSX.Element {
const onDeleteLimitHandler = (): void => {
if (activeSignal && activeSignal.id) {
deleteLimitForKey(
{
pathParams: { limitId: activeSignal.id },
},
{
onSuccess: () => {
notifications.success({
message: 'Limit deleted successfully',
});
setIsDeleteModalOpen(false);
setIsDeleteLimitModalOpen(false);
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
deleteLimitForKey(activeSignal.id);
}
};
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const handleCreateAlert = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
let metricName = '';
@@ -914,61 +771,31 @@ function MultiIngestionSettings(): JSX.Element {
history.push(URL);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
{
title: 'Ingestion Key',
key: 'ingestion-key',
// eslint-disable-next-line sonarjs/cognitive-complexity
render: (APIKey: GatewaytypesIngestionKeyDTO): JSX.Element => {
const createdOn = APIKey?.created_at
? getFormattedTime(
dayjs(APIKey.created_at).toISOString(),
formatTimezoneAdjustedTimestamp,
)
: '';
render: (APIKey: IngestionKeyProps): JSX.Element => {
const createdOn = getFormattedTime(
APIKey.created_at,
formatTimezoneAdjustedTimestamp,
);
const expiresOn =
!APIKey?.expires_at ||
dayjs(APIKey?.expires_at).toISOString() === '0001-01-01T00:00:00.000Z'
!APIKey?.expires_at || APIKey?.expires_at === '0001-01-01T00:00:00Z'
? 'No Expiry'
: getFormattedTime(
dayjs(APIKey?.expires_at).toISOString(),
formatTimezoneAdjustedTimestamp,
);
: getFormattedTime(APIKey?.expires_at, formatTimezoneAdjustedTimestamp);
const updatedOn = APIKey?.updated_at
? getFormattedTime(
dayjs(APIKey.updated_at).toISOString(),
formatTimezoneAdjustedTimestamp,
)
: '';
const onCopyKey = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
if (APIKey?.value) {
handleCopyKey(APIKey.value);
}
};
const onEditKey = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
showEditModal(APIKey);
};
const onDeleteKey = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
showDeleteModal(APIKey);
};
const updatedOn = getFormattedTime(
APIKey?.updated_at,
formatTimezoneAdjustedTimestamp,
);
// Convert array of limits to a dictionary for quick access
const limitsDict: Record<string, LimitProps> = {};
APIKey.limits?.forEach((limitItem) => {
if (limitItem.signal && limitItem.id) {
limitsDict[limitItem.signal] = limitItem as LimitProps;
}
APIKey.limits?.forEach((limitItem: LimitProps) => {
limitsDict[limitItem.signal] = limitItem;
});
const hasLimits = (signalName: string): boolean => !!limitsDict[signalName];
@@ -985,25 +812,39 @@ function MultiIngestionSettings(): JSX.Element {
<div className="ingestion-key-value">
<Typography.Text>
{APIKey?.value?.substring(0, 2)}********
{APIKey?.value
?.substring(APIKey?.value?.length ? APIKey.value.length - 2 : 0)
?.trim()}
{APIKey?.value.substring(0, 2)}********
{APIKey?.value.substring(APIKey.value.length - 2).trim()}
</Typography.Text>
<Copy className="copy-key-btn" size={12} onClick={onCopyKey} />
<Copy
className="copy-key-btn"
size={12}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleCopyKey(APIKey.value);
}}
/>
</div>
</div>
<div className="action-btn">
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
onClick={onEditKey}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showEditModal(APIKey);
}}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showDeleteModal(APIKey);
}}
/>
</div>
</div>
@@ -1013,7 +854,7 @@ function MultiIngestionSettings(): JSX.Element {
<Row>
<Col span={6}> ID </Col>
<Col span={12}>
<Typography.Text>{APIKey?.id}</Typography.Text>
<Typography.Text>{APIKey.id}</Typography.Text>
</Col>
</Row>
@@ -1065,39 +906,6 @@ function MultiIngestionSettings(): JSX.Element {
limit?.config?.second?.size !== undefined ||
limit?.config?.second?.count !== undefined;
const onEditSignalLimit = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, limit);
};
const onDeleteSignalLimit = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
showDeleteLimitModal(APIKey, limit);
};
const onAddSignalLimit = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, {
id: signalName,
signal: signalName,
config: {},
});
};
const onSaveSignalLimit = (): void => {
if (!hasLimits(signalName)) {
handleAddLimit(APIKey, signalName);
} else {
handleUpdateLimit(APIKey, limitsDict[signalName]);
}
};
const onCreateSignalAlert = (): void =>
handleCreateAlert(APIKey, limitsDict[signalName]);
return (
<div className="signal" key={signalName}>
<div className="header">
@@ -1108,18 +916,22 @@ function MultiIngestionSettings(): JSX.Element {
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, limit);
}}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showDeleteLimitModal(APIKey, limit);
}}
/>
</>
) : (
@@ -1128,8 +940,16 @@ function MultiIngestionSettings(): JSX.Element {
size="small"
shape="round"
icon={<PlusIcon size={14} />}
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
onClick={onAddSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, {
id: signalName,
signal: signalName,
config: {},
});
}}
>
Limits
</Button>
@@ -1138,7 +958,7 @@ function MultiIngestionSettings(): JSX.Element {
</div>
<div className="signal-limit-values">
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal?.signal === signalName &&
isEditAddLimitOpen ? (
<Form
@@ -1334,27 +1154,27 @@ function MultiIngestionSettings(): JSX.Element {
</div>
</div>
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
!isLoadingLimitForKey &&
hasCreateLimitForIngestionKeyError &&
createLimitForIngestionKeyError && (
createLimitForIngestionKeyError?.error && (
<div className="error">
{createLimitForIngestionKeyError}
{createLimitForIngestionKeyError?.error}
</div>
)}
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
!isLoadingLimitForKey &&
hasUpdateLimitForIngestionKeyError &&
updateLimitForIngestionKeyError && (
updateLimitForIngestionKeyError?.error && (
<div className="error">
{updateLimitForIngestionKeyError}
{updateLimitForIngestionKeyError?.error}
</div>
)}
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
isEditAddLimitOpen && (
<div className="signal-limit-save-discard">
@@ -1368,7 +1188,13 @@ function MultiIngestionSettings(): JSX.Element {
loading={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={onSaveSignalLimit}
onClick={(): void => {
if (!hasLimits(signalName)) {
handleAddLimit(APIKey, signalName);
} else {
handleUpdateLimit(APIKey, limitsDict[signalName]);
}
}}
>
Save
</Button>
@@ -1449,7 +1275,9 @@ function MultiIngestionSettings(): JSX.Element {
className="set-alert-btn periscope-btn ghost"
type="text"
data-testid={`set-alert-btn-${signalName}`}
onClick={onCreateSignalAlert}
onClick={(): void =>
handleCreateAlert(APIKey, limitsDict[signalName])
}
/>
</Tooltip>
)}
@@ -1564,7 +1392,7 @@ function MultiIngestionSettings(): JSX.Element {
const handleTableChange = (pagination: TablePaginationConfig): void => {
setPaginationParams({
page: pagination?.current || 1,
per_page: INITIAL_PAGE_SIZE,
per_page: 10,
});
};
@@ -1662,7 +1490,7 @@ function MultiIngestionSettings(): JSX.Element {
showHeader={false}
onChange={handleTableChange}
pagination={{
pageSize: isSearching ? SEARCH_PAGE_SIZE : paginationParams?.per_page,
pageSize: paginationParams?.per_page,
hideOnSinglePage: true,
showTotal: (total: number, range: number[]): string =>
`${range[0]}-${range[1]} of ${total} Ingestion keys`,

View File

@@ -1,4 +1,3 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -19,12 +18,6 @@ interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
data: TestIngestionKeyProps[];
}
// Gateway API response type (uses actual schema types for contract safety)
interface TestGatewayIngestionKeysResponse {
status: string;
data: GatewaytypesGettableIngestionKeysDTO;
}
// Mock useHistory.push to capture navigation URL used by MultiIngestionSettings
const mockPush = jest.fn() as jest.MockedFunction<(path: string) => void>;
jest.mock('react-router-dom', () => {
@@ -93,34 +86,32 @@ describe('MultiIngestionSettings Page', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Arrange API response with a metrics daily count limit so the alert button is visible
const response: TestGatewayIngestionKeysResponse = {
const response: TestAllIngestionKeyProps = {
status: 'success',
data: {
keys: [
{
name: 'Key One',
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [
{
id: 'l1',
signal: 'metrics',
config: { day: { count: 1000 } },
},
],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
},
data: [
{
name: 'Key One',
expires_at: TEST_EXPIRES_AT,
value: 'secret',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: TEST_CREATED_UPDATED,
updated_at: TEST_CREATED_UPDATED,
tags: [],
limits: [
{
id: 'l1',
signal: 'metrics',
config: { day: { count: 1000 } },
},
],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
};
server.use(
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
@@ -266,95 +257,4 @@ describe('MultiIngestionSettings Page', () => {
'signoz.meter.log.size',
);
});
it('switches to search API when search text is entered', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const getResponse: TestGatewayIngestionKeysResponse = {
status: 'success',
data: {
keys: [
{
name: 'Key Regular',
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret1',
workspace_id: TEST_WORKSPACE_ID,
id: 'k1',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
},
};
const searchResponse: TestGatewayIngestionKeysResponse = {
status: 'success',
data: {
keys: [
{
name: 'Key Search Result',
expires_at: new Date(TEST_EXPIRES_AT),
value: 'secret2',
workspace_id: TEST_WORKSPACE_ID,
id: 'k2',
created_at: new Date(TEST_CREATED_UPDATED),
updated_at: new Date(TEST_CREATED_UPDATED),
tags: [],
limits: [],
},
],
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
},
};
const getHandler = jest.fn();
const searchHandler = jest.fn();
server.use(
rest.get('*/api/v2/gateway/ingestion_keys', (req, res, ctx) => {
if (req.url.pathname.endsWith('/search')) {
return undefined;
}
getHandler();
return res(ctx.status(200), ctx.json(getResponse));
}),
rest.get('*/api/v2/gateway/ingestion_keys/search', (_req, res, ctx) => {
searchHandler();
return res(ctx.status(200), ctx.json(searchResponse));
}),
);
render(<MultiIngestionSettings />, undefined, {
initialRoute: INGESTION_SETTINGS_ROUTE,
});
await screen.findByText('Key Regular');
expect(getHandler).toHaveBeenCalled();
expect(searchHandler).not.toHaveBeenCalled();
// Reset getHandler count to verify it's not called again during search
getHandler.mockClear();
// Type in search box
const searchInput = screen.getByPlaceholderText(
'Search for ingestion key...',
);
await user.type(searchInput, 'test');
await screen.findByText('Key Search Result');
expect(searchHandler).toHaveBeenCalled();
expect(getHandler).not.toHaveBeenCalled();
// Clear search
searchHandler.mockClear();
getHandler.mockClear();
await user.clear(searchInput);
await screen.findByText('Key Regular');
// Search API should be disabled when not searching
expect(searchHandler).not.toHaveBeenCalled();
});
});

View File

@@ -35,10 +35,10 @@
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
.more-filter-actions {
.group-by-clause {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
@@ -53,7 +53,7 @@
}
}
.more-filter-actions:hover {
.group-by-clause:hover {
background-color: unset !important;
}
}
@@ -65,7 +65,7 @@
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important;
.more-filter-actions {
.group-by-clause {
color: var(--bg-ink-400);
}
}

View File

@@ -1,5 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable sonarjs/cognitive-complexity */
import React, { useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
@@ -17,12 +16,7 @@ import { MetricsType } from 'container/MetricsApplication/constant';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import {
ArrowDownToDot,
ArrowUpFromDot,
Ellipsis,
RefreshCw,
} from 'lucide-react';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import {
@@ -211,70 +205,6 @@ export default function TableViewActions(
viewName,
]);
const handleReplaceFilter = useCallback((): void => {
if (!stagedQuery) {
return;
}
const normalizedDataType: DataTypes | undefined =
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
? (dataType as DataTypes)
: undefined;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => {
// Only replace filters for index 0
if (index === 0) {
const newFilterItem: BaseAutocompleteData = {
key: fieldFilterKey,
type: fieldType || '',
dataType: normalizedDataType,
};
// Create new filter items array with single IN filter
const newFilters = {
items: [
{
id: '',
key: newFilterItem,
op: OPERATORS.IN,
value: [parseFieldValue(fieldData.value)],
},
],
op: 'AND',
};
// Clear the expression and update filters
return {
...item,
filters: newFilters,
filter: { expression: '' },
};
}
return item;
},
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
}, [
stagedQuery,
updateQueriesData,
fieldFilterKey,
fieldType,
dataType,
fieldData,
handleChangeSelectedView,
viewName,
]);
// Memoize textToCopy computation
const textToCopy = useMemo(() => {
let text = fieldData.value;
@@ -397,21 +327,13 @@ export default function TableViewActions(
content={
<div>
<Button
className="more-filter-actions"
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
<Button
className="more-filter-actions"
type="text"
icon={<RefreshCw size={14} />}
onClick={handleReplaceFilter}
>
Replace filters with this value
</Button>
</div>
}
rootClassName="table-view-actions-content"
@@ -483,21 +405,13 @@ export default function TableViewActions(
content={
<div>
<Button
className="more-filter-actions"
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
<Button
className="more-filter-actions"
type="text"
icon={<RefreshCw size={14} />}
onClick={handleReplaceFilter}
>
Replace filters with this value
</Button>
</div>
}
rootClassName="table-view-actions-content"

View File

@@ -407,10 +407,6 @@
color: var(--text-neutral-light-200) !important;
}
.ant-select-selection-item {
color: var(--text-ink-500) !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/button';
import { Form, Input, Select, Typography } from 'antd';
import { Form, Input, Select, Tooltip, Typography } from 'antd';
import getVersion from 'api/v1/version/get';
import get from 'api/v2/sessions/context/get';
import post from 'api/v2/sessions/email_password/post';
@@ -220,20 +220,6 @@ function Login(): JSX.Element {
}
};
const handleForgotPasswordClick = useCallback((): void => {
const email = form.getFieldValue('email');
if (!email || !sessionsContext || !sessionsContext?.orgs?.length) {
return;
}
history.push(ROUTES.FORGOT_PASSWORD, {
email,
orgId: sessionsOrgId,
orgs: sessionsContext.orgs,
});
}, [form, sessionsContext, sessionsOrgId]);
useEffect(() => {
if (callbackAuthError) {
setErrorMessage(
@@ -359,16 +345,11 @@ function Login(): JSX.Element {
<ParentContainer>
<div className="password-label-container">
<Label htmlFor="Password">Password</Label>
<Typography.Link
className="forgot-password-link"
href="#"
onClick={(event): void => {
event.preventDefault();
handleForgotPasswordClick();
}}
>
Forgot password?
</Typography.Link>
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
<Typography.Link className="forgot-password-link">
Forgot password?
</Typography.Link>
</Tooltip>
</div>
<FormContainer.Item name="password">
<Input.Password

View File

@@ -2,16 +2,13 @@ import { useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Skeleton, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetIngestionKeys } from 'api/generated/services/gateway';
import {
GatewaytypesIngestionKeyDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { DOCS_BASE_URL } from 'constants/app';
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowUpRight, Copy, Info, Key, TriangleAlert } from 'lucide-react';
import { IngestionKeyProps } from 'types/api/ingestionKeys/types';
import './IngestionDetails.styles.scss';
@@ -42,17 +39,17 @@ export default function OnboardingIngestionDetails(): JSX.Element {
const { notifications } = useNotifications();
const [, handleCopyToClipboard] = useCopyToClipboard();
const [
firstIngestionKey,
setFirstIngestionKey,
] = useState<GatewaytypesIngestionKeyDTO>({} as GatewaytypesIngestionKeyDTO);
const [firstIngestionKey, setFirstIngestionKey] = useState<IngestionKeyProps>(
{} as IngestionKeyProps,
);
const {
data: ingestionKeys,
isLoading: isIngestionKeysLoading,
error,
isError,
} = useGetIngestionKeys({
} = useGetAllIngestionsKeys({
search: '',
page: 1,
per_page: 10,
});
@@ -72,11 +69,8 @@ export default function OnboardingIngestionDetails(): JSX.Element {
};
useEffect(() => {
if (
ingestionKeys?.data?.data?.keys &&
ingestionKeys?.data.data.keys.length > 0
) {
setFirstIngestionKey(ingestionKeys?.data.data.keys[0]);
if (ingestionKeys?.data.data && ingestionKeys?.data.data.length > 0) {
setFirstIngestionKey(ingestionKeys?.data.data[0]);
}
}, [ingestionKeys]);
@@ -86,10 +80,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
<div className="ingestion-endpoint-section-error-container">
<Typography.Text className="ingestion-endpoint-section-error-text error">
<TriangleAlert size={14} />{' '}
{(error as AxiosError<RenderErrorResponseDTO>)?.response?.data?.error
?.message ||
(error as AxiosError)?.message ||
'Something went wrong'}
{(error as AxiosError)?.message || 'Something went wrong'}
</Typography.Text>
<div className="ingestion-setup-details-links">
@@ -185,7 +176,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
</Typography.Text>
<Typography.Text className="ingestion-key-value-copy">
{maskKey(firstIngestionKey?.value || '')}
{maskKey(firstIngestionKey?.value)}
<Copy
size={14}
@@ -195,9 +186,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INGESTION_KEY_COPIED}`,
{},
);
if (firstIngestionKey?.value) {
handleCopyKey(firstIngestionKey.value);
}
handleCopyKey(firstIngestionKey?.value);
}}
/>
</Typography.Text>

View File

@@ -32,7 +32,6 @@ export const routeConfig: Record<string, QueryParams[]> = {
[ROUTES.LIST_ALL_ALERT]: [QueryParams.resourceAttributes],
[ROUTES.LIST_LICENSES]: [QueryParams.resourceAttributes],
[ROUTES.LOGIN]: [QueryParams.resourceAttributes],
[ROUTES.FORGOT_PASSWORD]: [QueryParams.resourceAttributes],
[ROUTES.LOGS]: [QueryParams.resourceAttributes],
[ROUTES.LOGS_BASE]: [QueryParams.resourceAttributes],
[ROUTES.MY_SETTINGS]: [QueryParams.resourceAttributes],

View File

@@ -1,8 +0,0 @@
import { TimeSeriesTooltipProps } from '../types';
import Tooltip from './Tooltip';
export default function TimeSeriesTooltip(
props: TimeSeriesTooltipProps,
): JSX.Element {
return <Tooltip {...props} />;
}

View File

@@ -5,7 +5,8 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipProps } from '../types';
import { TooltipContentItem, TooltipProps } from '../types';
import { buildTooltipContent } from './utils';
import './Tooltip.styles.scss';
@@ -13,14 +14,14 @@ const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
export default function Tooltip({
seriesIndex,
dataIndexes,
uPlotInstance,
timezone,
content,
yAxisUnit = '',
decimalPrecision,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const tooltipContent = content ?? [];
const headerTitle = useMemo(() => {
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
@@ -32,6 +33,20 @@ export default function Tooltip({
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}),
[uPlotInstance, seriesIndex, dataIndexes, yAxisUnit, decimalPrecision],
);
return (
<div
className={cx(
@@ -45,16 +60,16 @@ export default function Tooltip({
<div
style={{
height: Math.min(
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
content.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
minHeight: 0,
}}
>
{tooltipContent.length > 0 ? (
{content.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data={tooltipContent}
data={content}
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item">

View File

@@ -20,27 +20,6 @@ export function resolveSeriesColor(
return FALLBACK_SERIES_COLOR;
}
export function getTooltipBaseValue({
data,
index,
dataIndex,
isStackedBarChart,
}: {
data: AlignedData;
index: number;
dataIndex: number;
isStackedBarChart?: boolean;
}): number | null {
let baseValue = data[index][dataIndex] ?? null;
if (isStackedBarChart && index + 1 < data.length && baseValue !== null) {
const nextValue = data[index + 1][dataIndex] ?? null;
if (nextValue !== null) {
baseValue = baseValue - nextValue;
}
}
return baseValue;
}
export function buildTooltipContent({
data,
series,
@@ -49,7 +28,6 @@ export function buildTooltipContent({
uPlotInstance,
yAxisUnit,
decimalPrecision,
isStackedBarChart,
}: {
data: AlignedData;
series: Series[];
@@ -58,7 +36,6 @@ export function buildTooltipContent({
uPlotInstance: uPlot;
yAxisUnit: string;
decimalPrecision?: PrecisionOption;
isStackedBarChart?: boolean;
}): TooltipContentItem[] {
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
@@ -75,29 +52,23 @@ export function buildTooltipContent({
continue;
}
const baseValue = getTooltipBaseValue({
data,
index,
dataIndex,
isStackedBarChart,
});
const raw = data[index]?.[dataIndex];
const value = Number(raw);
const displayValue = Number.isNaN(value) ? 0 : value;
const isActive = index === activeSeriesIndex;
if (Number.isFinite(baseValue) && baseValue !== null) {
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: baseValue,
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
isActive,
};
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: displayValue,
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
isActive,
};
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
}

View File

@@ -59,22 +59,11 @@ export interface TooltipRenderArgs {
viaSync: boolean;
}
export interface BaseTooltipProps {
export type TooltipProps = TooltipRenderArgs & {
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
}
export interface TimeSeriesTooltipProps
extends BaseTooltipProps,
TooltipRenderArgs {}
export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
isStackedBarChart?: boolean;
}
export type TooltipProps = TimeSeriesTooltipProps | BarTooltipProps;
};
export enum LegendPosition {
BOTTOM = 'bottom',

View File

@@ -110,7 +110,6 @@ export enum LineStyle {
export enum DrawStyle {
Line = 'line',
Points = 'points',
Bar = 'bar',
}
export enum LineInterpolation {
@@ -129,7 +128,7 @@ export enum VisibilityMode {
export interface SeriesProps {
scaleKey: string;
label?: string;
panelType: PANEL_TYPES;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;

View File

@@ -1,13 +1,7 @@
import type {
CSSProperties,
MutableRefObject,
ReactNode,
RefObject,
} from 'react';
import type uPlot from 'uplot';
import { CSSProperties } from 'react';
import type { TooltipRenderArgs } from '../../components/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
@@ -23,7 +17,7 @@ export interface TooltipViewState {
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
contents?: ReactNode;
contents?: React.ReactNode;
}
export interface TooltipLayoutInfo {
@@ -37,7 +31,7 @@ export interface TooltipPluginProps {
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
render: (args: TooltipRenderArgs) => ReactNode;
render: (args: TooltipRenderArgs) => React.ReactNode;
maxWidth?: number;
maxHeight?: number;
}
@@ -81,11 +75,13 @@ export interface TooltipControllerState {
*/
export interface TooltipControllerContext {
controller: TooltipControllerState;
layoutRef: MutableRefObject<TooltipLayoutInfo | undefined>;
containerRef: RefObject<HTMLDivElement | null>;
rafId: MutableRefObject<number | null>;
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
containerRef: React.RefObject<HTMLDivElement | null>;
rafId: React.MutableRefObject<number | null>;
updateState: (updates: Partial<TooltipViewState>) => void;
renderRef: MutableRefObject<(args: TooltipRenderArgs) => ReactNode>;
renderRef: React.MutableRefObject<
(args: TooltipRenderArgs) => React.ReactNode
>;
syncMode: DashboardCursorSync;
syncKey: string;
canPinTooltip: boolean;

View File

@@ -1,484 +0,0 @@
import React from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'tests/test-utils';
import type uPlot from 'uplot';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import { DashboardCursorSync } from '../TooltipPlugin/types';
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
type HookHandler = (...args: unknown[]) => void;
class TestConfigBuilder extends UPlotConfigBuilder {
public registeredHooks: { type: string; handler: HookHandler }[] = [];
public removeCallbacks: jest.Mock[] = [];
// Override addHook so we can:
// - capture handlers by hook name for tests
// - return removable jest mocks to assert cleanup
public addHook<T extends keyof uPlot.Hooks.Defs>(
type: T,
hook: uPlot.Hooks.Defs[T],
): () => void {
this.registeredHooks.push({
type: String(type),
handler: hook as HookHandler,
});
const remove = jest.fn();
this.removeCallbacks.push(remove);
return remove;
}
}
type ConfigMock = TestConfigBuilder;
function createConfigMock(): ConfigMock {
return new TestConfigBuilder();
}
function getHandler(config: ConfigMock, hookName: string): HookHandler {
const entry = config.registeredHooks.find((h) => h.type === hookName);
if (!entry) {
throw new Error(`Hook "${hookName}" was not registered on config`);
}
return entry.handler;
}
function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown> };
} {
return {
over: document.createElement('div'),
setCursor: jest.fn(),
cursor: { event: {} },
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TooltipPlugin', () => {
beforeEach(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
(callback as FrameRequestCallback)(0);
return 0;
});
jest
.spyOn(window, 'cancelAnimationFrame')
// eslint-disable-next-line @typescript-eslint/no-empty-function
.mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
/**
* Shorthand: render the plugin, initialise a fake plot, and trigger a
* series focus so the tooltip becomes visible. Returns the fake plot
* instance for further interaction (e.g. clicking the overlay).
*/
function renderAndActivateHover(
config: ConfigMock,
renderFn: (
args: TooltipRenderArgs,
) => React.ReactNode = (): React.ReactNode =>
React.createElement('div', null, 'tooltip-body'),
extraProps: Partial<React.ComponentProps<typeof TooltipPlugin>> = {},
): ReturnType<typeof createFakePlot> {
render(
React.createElement(TooltipPlugin, {
config,
render: renderFn,
syncMode: DashboardCursorSync.None,
...extraProps,
}),
);
const fakePlot = createFakePlot();
const initHandler = getHandler(config, 'init');
const setSeriesHandler = getHandler(config, 'setSeries');
act(() => {
initHandler(fakePlot);
setSeriesHandler(fakePlot, 1, { focus: true });
});
return fakePlot;
}
// ---- Initial state --------------------------------------------------------
describe('before any interaction', () => {
it('does not render anything when there is no active hover', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
}),
);
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
});
it('registers all required uPlot hooks on mount', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
}),
);
const registered = config.registeredHooks.map((h) => h.type);
expect(registered).toContain('ready');
expect(registered).toContain('init');
expect(registered).toContain('setData');
expect(registered).toContain('setSeries');
expect(registered).toContain('setLegend');
expect(registered).toContain('setCursor');
});
});
// ---- Tooltip rendering ------------------------------------------------------
describe('tooltip rendering', () => {
it('renders contents into a portal on document.body when hover is active', () => {
const config = createConfigMock();
const renderTooltip = jest.fn(() =>
React.createElement('div', null, 'tooltip-body'),
);
renderAndActivateHover(config, renderTooltip);
expect(renderTooltip).toHaveBeenCalled();
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
});
// ---- Pin behaviour ----------------------------------------------------------
describe('pin behaviour', () => {
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
const config = createConfigMock();
const fakePlot = renderAndActivateHover(config, undefined, {
canPinTooltip: true,
});
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container.classList.contains('pinned')).toBe(false);
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
return waitFor(() => {
const updated = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(updated).not.toBeNull();
expect(updated?.classList.contains('pinned')).toBe(true);
});
});
it('dismisses a pinned tooltip via the dismiss callback', async () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: (args: TooltipRenderArgs) =>
React.createElement(
'button',
{ type: 'button', onClick: args.dismiss },
'Dismiss',
),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
// Pin the tooltip.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Wait until the tooltip is actually pinned (pointer events enabled)
await waitFor(() => {
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(container).not.toBeNull();
expect(container?.classList.contains('pinned')).toBe(true);
});
const button = await screen.findByRole('button', { name: 'Dismiss' });
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(button);
await waitFor(() => {
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
});
});
it('drops a pinned tooltip when the underlying data changes', () => {
jest.useFakeTimers();
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config: config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
jest.runAllTimers();
});
// Pin.
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
(document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement)?.classList.contains('pinned'),
).toBe(true);
// Simulate data update should dismiss the pinned tooltip.
act(() => {
getHandler(config, 'setData')(fakePlot);
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
jest.useRealTimers();
});
it('unpins the tooltip on outside mousedown', () => {
jest.useFakeTimers();
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'pinned content'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
jest.runAllTimers();
});
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Click outside the tooltip container.
act(() => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
jest.useRealTimers();
});
it('unpins the tooltip on outside keydown', () => {
jest.useFakeTimers();
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'pinned content'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
jest.runAllTimers();
});
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Press a key outside the tooltip.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
jest.useRealTimers();
});
});
// ---- Cursor sync ------------------------------------------------------------
describe('cursor sync', () => {
it('enables uPlot cursor sync for time-based scales when mode is Tooltip', () => {
const config = createConfigMock();
const setCursorSpy = jest.spyOn(config, 'setCursor');
config.addScale({ scaleKey: 'x', time: true });
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.Tooltip,
syncKey: 'dashboard-sync',
}),
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', null] },
});
});
it('does not enable cursor sync when mode is None', () => {
const config = createConfigMock();
const setCursorSpy = jest.spyOn(config, 'setCursor');
config.addScale({ scaleKey: 'x', time: true });
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
}),
);
expect(setCursorSpy).not.toHaveBeenCalled();
});
it('does not enable cursor sync when scale is not time-based', () => {
const config = createConfigMock();
const setCursorSpy = jest.spyOn(config, 'setCursor');
config.addScale({ scaleKey: 'x', time: false });
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.Tooltip,
}),
);
expect(setCursorSpy).not.toHaveBeenCalled();
});
});
// ---- Cleanup ----------------------------------------------------------------
describe('cleanup on unmount', () => {
it('removes window event listeners and all uPlot hooks', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
}),
);
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
expect(resizeCall).toBeDefined();
expect(scrollCall).toBeDefined();
const resizeListener = resizeCall?.[1] as EventListener;
const scrollListener = scrollCall?.[1] as EventListener;
const scrollOptions = scrollCall?.[2];
unmount();
config.removeCallbacks.forEach((removeFn) => {
expect(removeFn).toHaveBeenCalledTimes(1);
});
expect(removeSpy).toHaveBeenCalledWith('resize', resizeListener);
expect(removeSpy).toHaveBeenCalledWith(
'scroll',
scrollListener,
scrollOptions,
);
});
});
});

View File

@@ -1,62 +0,0 @@
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
it('treats null and undefined as invalid', () => {
expect(isInvalidPlotValue(null)).toBe(true);
expect(isInvalidPlotValue(undefined)).toBe(true);
});
it('treats finite numbers as valid and non-finite as invalid', () => {
expect(isInvalidPlotValue(0)).toBe(false);
expect(isInvalidPlotValue(123.45)).toBe(false);
expect(isInvalidPlotValue(Number.NaN)).toBe(true);
expect(isInvalidPlotValue(Infinity)).toBe(true);
expect(isInvalidPlotValue(-Infinity)).toBe(true);
});
it('treats well-formed numeric strings as valid', () => {
expect(isInvalidPlotValue('0')).toBe(false);
expect(isInvalidPlotValue('123.45')).toBe(false);
expect(isInvalidPlotValue('-1')).toBe(false);
});
it('treats Infinity/NaN string variants and non-numeric strings as invalid', () => {
expect(isInvalidPlotValue('+Inf')).toBe(true);
expect(isInvalidPlotValue('-Inf')).toBe(true);
expect(isInvalidPlotValue('Infinity')).toBe(true);
expect(isInvalidPlotValue('-Infinity')).toBe(true);
expect(isInvalidPlotValue('NaN')).toBe(true);
expect(isInvalidPlotValue('not-a-number')).toBe(true);
});
it('treats non-number, non-string values as valid (left to caller)', () => {
expect(isInvalidPlotValue({})).toBe(false);
expect(isInvalidPlotValue([])).toBe(false);
expect(isInvalidPlotValue(true)).toBe(false);
});
});
describe('normalizePlotValue', () => {
it('returns null for invalid values detected by isInvalidPlotValue', () => {
expect(normalizePlotValue(null)).toBeNull();
expect(normalizePlotValue(undefined)).toBeNull();
expect(normalizePlotValue(NaN)).toBeNull();
expect(normalizePlotValue(Infinity)).toBeNull();
expect(normalizePlotValue('-Infinity')).toBeNull();
expect(normalizePlotValue('not-a-number')).toBeNull();
});
it('parses valid numeric strings into numbers', () => {
expect(normalizePlotValue('0')).toBe(0);
expect(normalizePlotValue('123.45')).toBe(123.45);
expect(normalizePlotValue('-1')).toBe(-1);
});
it('passes through valid numbers unchanged', () => {
expect(normalizePlotValue(0)).toBe(0);
expect(normalizePlotValue(123)).toBe(123);
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
});

View File

@@ -1,201 +0,0 @@
import uPlot from 'uplot';
import { DistributionType } from '../../config/types';
import * as scaleUtils from '../scale';
describe('scale utils', () => {
describe('normalizeLogScaleLimits', () => {
it('returns limits unchanged when distribution is not logarithmic', () => {
const limits = {
min: 1,
max: 100,
softMin: 5,
softMax: 50,
};
const result = scaleUtils.normalizeLogScaleLimits({
distr: DistributionType.Linear,
logBase: 10,
limits,
});
expect(result).toEqual(limits);
});
it('snaps positive limits to powers of the log base when distribution is logarithmic', () => {
const result = scaleUtils.normalizeLogScaleLimits({
distr: DistributionType.Logarithmic,
logBase: 10,
limits: {
min: 3,
max: 900,
softMin: 12,
softMax: 85,
},
});
expect(result.min).toBe(1); // 10^0
expect(result.max).toBe(1000); // 10^3
expect(result.softMin).toBe(10); // 10^1
expect(result.softMax).toBe(100); // 10^2
});
});
describe('getDistributionConfig', () => {
it('returns empty config for time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: true,
distr: DistributionType.Linear,
logBase: 2,
});
expect(config).toEqual({});
});
it('returns linear distribution settings for non-time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.Linear,
logBase: 2,
});
expect(config.distr).toBe(1);
expect(config.log).toBe(2);
});
it('returns log distribution settings for non-time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.Logarithmic,
logBase: 10,
});
expect(config.distr).toBe(3);
expect(config.log).toBe(10);
});
});
describe('getRangeConfig', () => {
it('computes range config and fixed range flags correctly', () => {
const {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
} = scaleUtils.getRangeConfig(0, 100, null, null, 0.1, 0.2);
expect(rangeConfig.min).toEqual({
pad: 0.1,
hard: 0,
soft: undefined,
mode: 3,
});
expect(rangeConfig.max).toEqual({
pad: 0.2,
hard: 100,
soft: undefined,
mode: 3,
});
expect(hardMinOnly).toBe(true);
expect(hardMaxOnly).toBe(true);
expect(hasFixedRange).toBe(true);
});
});
describe('createRangeFunction', () => {
it('returns [dataMin, dataMax] when no fixed range and no data', () => {
const params = {
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: false,
hardMaxOnly: false,
hasFixedRange: false,
min: null,
max: null,
};
const rangeFn = scaleUtils.createRangeFunction(params);
const u = ({
scales: {
y: {
distr: 1,
log: 10,
},
},
} as unknown) as uPlot;
const result = rangeFn(
u,
(null as unknown) as number,
(null as unknown) as number,
'y',
);
expect(result).toEqual([null, null]);
});
it('applies hard min/max for linear scale when only hard limits are set', () => {
const params = {
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: true,
hardMaxOnly: true,
hasFixedRange: true,
min: 0,
max: 100,
};
const rangeFn = scaleUtils.createRangeFunction(params);
// Use an undefined distr so the range function skips calling uPlot.rangeNum
// and we can focus on the behavior of applyHardLimits.
const u = ({
scales: {
y: {
distr: undefined,
log: 10,
},
},
} as unknown) as uPlot;
const result = rangeFn(u, 10, 20, 'y');
// After applyHardLimits, the returned range should respect configured min/max
expect(result).toEqual([0, 100]);
});
});
describe('adjustSoftLimitsWithThresholds', () => {
it('returns original soft limits when there are no thresholds', () => {
const result = scaleUtils.adjustSoftLimitsWithThresholds(1, 5, [], 'ms');
expect(result).toEqual({ softMin: 1, softMax: 5 });
});
it('expands soft limits to include threshold min/max values', () => {
const result = scaleUtils.adjustSoftLimitsWithThresholds(
3,
6,
[{ thresholdValue: 2 }, { thresholdValue: 8 }],
'ms',
);
// min should be pulled down to the smallest threshold value
expect(result.softMin).toBe(2);
// max should be pushed up to the largest threshold value
expect(result.softMax).toBe(8);
});
});
describe('getFallbackMinMaxTimeStamp', () => {
it('returns a 24-hour window ending at approximately now', () => {
const { fallbackMin, fallbackMax } = scaleUtils.getFallbackMinMaxTimeStamp();
// Difference should be exactly one day in seconds
expect(fallbackMax - fallbackMin).toBe(86400);
// Both should be reasonable timestamps (not NaN or negative)
expect(fallbackMin).toBeGreaterThan(0);
expect(fallbackMax).toBeGreaterThan(fallbackMin);
});
});
});

View File

@@ -1,34 +0,0 @@
import { findMinMaxThresholdValues } from '../threshold';
describe('findMinMaxThresholdValues', () => {
it('returns [null, null] when thresholds array is empty or missing', () => {
expect(findMinMaxThresholdValues([], 'ms')).toEqual([null, null]);
});
it('returns min and max from thresholdValue when units are not provided', () => {
const thresholds = [
{ thresholdValue: 5 },
{ thresholdValue: 1 },
{ thresholdValue: 10 },
];
const [min, max] = findMinMaxThresholdValues(thresholds);
expect(min).toBe(1);
expect(max).toBe(10);
});
it('ignores thresholds without a value or with unconvertible units', () => {
const thresholds = [
// Should be ignored: convertValue returns null for unknown unit
{ thresholdValue: 100, thresholdUnit: 'unknown-unit' },
// Should be used
{ thresholdValue: 4 },
];
const [min, max] = findMinMaxThresholdValues(thresholds, 'ms');
expect(min).toBe(4);
expect(max).toBe(4);
});
});

View File

@@ -7,6 +7,4 @@ import { handlers } from './handlers';
// This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers);
export * from './utils';
export { rest };

View File

@@ -1,26 +0,0 @@
import { ResponseResolver, restContext, RestRequest } from 'msw';
export const createErrorResponse = (
status: number,
code: string,
message: string,
): ResponseResolver<RestRequest, typeof restContext> => (
_req,
res,
ctx,
): ReturnType<ResponseResolver<RestRequest, typeof restContext>> =>
res(
ctx.status(status),
ctx.json({
error: {
code,
message,
},
}),
);
export const handleInternalServerError = createErrorResponse(
500,
'INTERNAL_SERVER_ERROR',
'Internal server error occurred',
);

View File

@@ -1,46 +0,0 @@
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, waitFor } from 'tests/test-utils';
import ForgotPassword from '../index';
// Mock dependencies
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
describe('ForgotPassword Page', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Route State Handling', () => {
it('redirects to login when route state is missing', async () => {
render(<ForgotPassword />, undefined, {
initialRoute: '/forgot-password',
});
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
it('returns null when route state is missing', () => {
const { container } = render(<ForgotPassword />, undefined, {
initialRoute: '/forgot-password',
});
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -1,39 +0,0 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import ForgotPasswordContainer, {
ForgotPasswordRouteState,
} from 'container/ForgotPassword';
import history from 'lib/history';
import '../Login/Login.styles.scss';
function ForgotPassword(): JSX.Element | null {
const location = useLocation<ForgotPasswordRouteState | undefined>();
const routeState = location.state;
useEffect(() => {
if (!routeState?.email) {
history.push(ROUTES.LOGIN);
}
}, [routeState]);
if (!routeState?.email) {
return null;
}
return (
<AuthPageContainer>
<div className="auth-form-card">
<ForgotPasswordContainer
email={routeState.email}
orgId={routeState.orgId}
orgs={routeState.orgs}
/>
</div>
</AuthPageContainer>
);
}
export default ForgotPassword;

View File

@@ -6,8 +6,6 @@ const DOCLINKS = {
'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
METRICS_EXPLORER_EMPTY_STATE:
'https://signoz.io/docs/userguide/send-metrics-cloud/',
EXTERNAL_API_MONITORING:
'https://signoz.io/docs/external-api-monitoring/overview/',
};
export default DOCLINKS;

View File

@@ -68,7 +68,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERT_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'],
FORGOT_PASSWORD: ['ADMIN', 'EDITOR', 'VIEWER'],
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -5038,7 +5038,7 @@
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
"@signozhq/icons@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
integrity sha512-kGWDhCpQkFWaNwyWfy88AIbg902wBbgTFTBAtmo6DkHyLGoqWAf0Jcq8BX+7brFqJF9PnLoSJDj1lvCpUsI/Ig==

View File

@@ -0,0 +1,44 @@
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
)
// Recovery is a middleware that recovers from panics, logs the panic,
// and returns a 500 Internal Server Error.
type Recovery struct {
logger *slog.Logger
}
// NewRecovery creates a new Recovery middleware.
func NewRecovery(logger *slog.Logger) Wrapper {
return &Recovery{
logger: logger.With("pkg", "http-middleware-recovery"),
}
}
// Wrap is the middleware handler.
func (m *Recovery) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
m.logger.ErrorContext(
r.Context(),
"panic recovered",
"err", err, "stack", string(debug.Stack()),
)
render.Error(w, errors.NewInternalf(
errors.CodeInternal, "internal server error",
))
}
}()
next.ServeHTTP(w, r)
})
}

View File

@@ -13,6 +13,7 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -462,7 +463,7 @@ func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationstypes.IntegrationUserEmails, createdByUser.Email) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
@@ -507,7 +508,7 @@ func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
if slices.Contains(integrationstypes.IntegrationUserEmails, createdByUser.Email) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
@@ -171,7 +172,7 @@ func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID)
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
// since assign is idempotent multiple calls to assign won't cause issues in case of retries.
err := module.authz.Grant(ctx, input.OrgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(input.Role), authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
@@ -286,7 +287,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
if slices.Contains(integrationstypes.IntegrationUserEmails, user.Email) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
@@ -300,7 +301,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
// since revoke is idempotent multiple calls to revoke won't cause issues in case of retries
err = module.authz.Revoke(ctx, orgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role), authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return err

View File

@@ -278,7 +278,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
var metricTemporality map[string]metrictypes.Temporality
if len(metricNames) > 0 {
var err error
metricTemporality, err = q.metadataStore.FetchTemporalityMulti(ctx, req.Start, req.End, metricNames...)
metricTemporality, err = q.metadataStore.FetchTemporalityMulti(ctx, metricNames...)
if err != nil {
q.logger.WarnContext(ctx, "failed to fetch metric temporality", "error", err, "metrics", metricNames)
// Continue without temporality - statement builder will handle unspecified

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,796 @@
package implawsprovider
import (
"context"
"fmt"
"log/slog"
"net/url"
"slices"
"sort"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
integrationstore "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/store"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
var (
CodeInvalidAWSRegion = errors.MustNewCode("invalid_aws_region")
CodeDashboardNotFound = errors.MustNewCode("dashboard_not_found")
)
type awsProvider struct {
logger *slog.Logger
querier querier.Querier
accountsRepo integrationstore.CloudProviderAccountsRepository
serviceConfigRepo integrationstore.ServiceConfigDatabase
awsServiceDefinitions *services.AWSServicesProvider
}
func NewAWSCloudProvider(
logger *slog.Logger,
accountsRepo integrationstore.CloudProviderAccountsRepository,
serviceConfigRepo integrationstore.ServiceConfigDatabase,
querier querier.Querier,
) integrationstypes.CloudProvider {
awsServiceDefinitions, err := services.NewAWSCloudProviderServices()
if err != nil {
panic("failed to initialize AWS service definitions: " + err.Error())
}
return &awsProvider{
logger: logger,
querier: querier,
accountsRepo: accountsRepo,
serviceConfigRepo: serviceConfigRepo,
awsServiceDefinitions: awsServiceDefinitions,
}
}
func (a *awsProvider) GetAccountStatus(ctx context.Context, orgID, accountID string) (*integrationstypes.GettableAccountStatus, error) {
accountRecord, err := a.accountsRepo.Get(ctx, orgID, a.GetName().String(), accountID)
if err != nil {
return nil, err
}
return &integrationstypes.GettableAccountStatus{
Id: accountRecord.ID.String(),
CloudAccountId: accountRecord.AccountID,
Status: accountRecord.Status(),
}, nil
}
func (a *awsProvider) ListConnectedAccounts(ctx context.Context, orgID string) (*integrationstypes.GettableConnectedAccountsList, error) {
accountRecords, err := a.accountsRepo.ListConnected(ctx, orgID, a.GetName().String())
if err != nil {
return nil, err
}
connectedAccounts := make([]*integrationstypes.Account, 0, len(accountRecords))
for _, r := range accountRecords {
connectedAccounts = append(connectedAccounts, r.Account(a.GetName()))
}
return &integrationstypes.GettableConnectedAccountsList{
Accounts: connectedAccounts,
}, nil
}
func (a *awsProvider) AgentCheckIn(ctx context.Context, req *integrationstypes.PostableAgentCheckInPayload) (any, error) {
// agent can't check in unless the account is already created
existingAccount, err := a.accountsRepo.Get(ctx, req.OrgID, a.GetName().String(), req.ID)
if err != nil {
return nil, err
}
if existingAccount != nil && existingAccount.AccountID != nil && *existingAccount.AccountID != req.AccountID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in with new %s account id %s for account %s with existing %s id %s",
a.GetName().String(), req.AccountID, existingAccount.ID.StringValue(), a.GetName().String(),
*existingAccount.AccountID,
))
}
existingAccount, err = a.accountsRepo.GetConnectedCloudAccount(ctx, req.OrgID, a.GetName().String(), req.AccountID)
if existingAccount != nil && existingAccount.ID.StringValue() != req.ID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in to %s account %s with id %s. already connected with id %s",
a.GetName().String(), req.AccountID, req.ID, existingAccount.ID.StringValue(),
))
}
agentReport := integrationstypes.AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
account, err := a.accountsRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), &req.ID, nil, &req.AccountID, &agentReport, nil,
)
if err != nil {
return nil, err
}
agentConfig, err := a.getAWSAgentConfig(ctx, account)
if err != nil {
return nil, err
}
return &integrationstypes.GettableAWSAgentCheckIn{
AccountId: account.ID.StringValue(),
CloudAccountId: *account.AccountID,
RemovedAt: account.RemovedAt,
IntegrationConfig: *agentConfig,
}, nil
}
func (a *awsProvider) getAWSAgentConfig(ctx context.Context, account *integrationstypes.CloudIntegration) (*integrationstypes.AWSAgentIntegrationConfig, error) {
// prepare and return integration config to be consumed by agent
agentConfig := &integrationstypes.AWSAgentIntegrationConfig{
EnabledRegions: []string{},
TelemetryCollectionStrategy: &integrationstypes.AWSCollectionStrategy{
Provider: a.GetName(),
AWSMetrics: &integrationstypes.AWSMetricsStrategy{},
AWSLogs: &integrationstypes.AWSLogsStrategy{},
S3Buckets: map[string][]string{},
},
}
accountConfig := new(integrationstypes.AWSAccountConfig)
err := accountConfig.Unmarshal(account.Config)
if err != nil {
return nil, err
}
if accountConfig != nil && accountConfig.EnabledRegions != nil {
agentConfig.EnabledRegions = accountConfig.EnabledRegions
}
svcConfigs, err := a.serviceConfigRepo.GetAllForAccount(
ctx, account.OrgID, account.ID.StringValue(),
)
if err != nil {
return nil, err
}
// accumulate config in a fixed order to ensure same config generated across runs
configuredServices := maps.Keys(svcConfigs)
slices.Sort(configuredServices)
for _, svcType := range configuredServices {
definition, err := a.awsServiceDefinitions.GetServiceDefinition(ctx, svcType)
if err != nil {
continue
}
config := svcConfigs[svcType]
serviceConfig := new(integrationstypes.AWSCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
continue
}
if serviceConfig.Logs != nil && serviceConfig.Logs.Enabled {
if svcType == integrationstypes.S3Sync {
// S3 bucket sync; No cloudwatch logs are appended for this service type;
// Though definition is populated with a custom cloudwatch group that helps in calculating logs connection status
agentConfig.TelemetryCollectionStrategy.S3Buckets = serviceConfig.Logs.S3Buckets
} else if definition.Strategy.AWSLogs != nil { // services that includes a logs subscription
agentConfig.TelemetryCollectionStrategy.AWSLogs.Subscriptions = append(
agentConfig.TelemetryCollectionStrategy.AWSLogs.Subscriptions,
definition.Strategy.AWSLogs.Subscriptions...,
)
}
}
if serviceConfig.Metrics != nil && serviceConfig.Metrics.Enabled && definition.Strategy.AWSMetrics != nil {
agentConfig.TelemetryCollectionStrategy.AWSMetrics.StreamFilters = append(
agentConfig.TelemetryCollectionStrategy.AWSMetrics.StreamFilters,
definition.Strategy.AWSMetrics.StreamFilters...,
)
}
}
return agentConfig, nil
}
func (a *awsProvider) GetName() integrationstypes.CloudProviderType {
return integrationstypes.CloudProviderAWS
}
func (a *awsProvider) ListServices(ctx context.Context, orgID string, cloudAccountID *string) (any, error) {
svcConfigs := make(map[string]*integrationstypes.AWSCloudServiceConfig)
if cloudAccountID != nil {
activeAccount, err := a.accountsRepo.GetConnectedCloudAccount(ctx, orgID, a.GetName().String(), *cloudAccountID)
if err != nil {
return nil, err
}
serviceConfigs, err := a.serviceConfigRepo.GetAllForAccount(ctx, orgID, activeAccount.ID.String())
if err != nil {
return nil, err
}
for svcType, config := range serviceConfigs {
serviceConfig := new(integrationstypes.AWSCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
return nil, err
}
svcConfigs[svcType] = serviceConfig
}
}
summaries := make([]integrationstypes.AWSServiceSummary, 0)
definitions, err := a.awsServiceDefinitions.ListServiceDefinitions(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf("couldn't list aws service definitions: %w", err))
}
for _, def := range definitions {
summary := integrationstypes.AWSServiceSummary{
DefinitionMetadata: def.DefinitionMetadata,
Config: nil,
}
summary.Config = svcConfigs[summary.Id]
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].DefinitionMetadata.Title < summaries[j].DefinitionMetadata.Title
})
return &integrationstypes.GettableAWSServices{
Services: summaries,
}, nil
}
func (a *awsProvider) GetServiceDetails(ctx context.Context, req *integrationstypes.GetServiceDetailsReq) (any, error) {
details := new(integrationstypes.GettableAWSServiceDetails)
awsDefinition, err := a.awsServiceDefinitions.GetServiceDefinition(ctx, req.ServiceId)
if err != nil {
return nil, model.InternalError(fmt.Errorf("couldn't get aws service definition: %w", err))
}
details.AWSServiceDefinition = *awsDefinition
details.Strategy.Provider = a.GetName()
if req.CloudAccountID == nil {
return details, nil
}
config, err := a.getServiceConfig(ctx, &details.AWSServiceDefinition, req.OrgID, a.GetName().String(), req.ServiceId, *req.CloudAccountID)
if err != nil {
return nil, err
}
if config == nil {
return details, nil
}
details.Config = config
connectionStatus, err := a.getServiceConnectionStatus(
ctx,
*req.CloudAccountID,
req.OrgID,
&details.AWSServiceDefinition,
config,
)
if err != nil {
return nil, err
}
details.ConnectionStatus = connectionStatus
return details, nil
}
func (a *awsProvider) getServiceConnectionStatus(
ctx context.Context,
cloudAccountID string,
orgID string,
def *integrationstypes.AWSServiceDefinition,
serviceConfig *integrationstypes.AWSCloudServiceConfig,
) (*integrationstypes.ServiceConnectionStatus, error) {
if def.Strategy == nil {
return nil, nil
}
resp := new(integrationstypes.ServiceConnectionStatus)
wg := sync.WaitGroup{}
wg.Add(2)
if def.Strategy.AWSMetrics != nil && serviceConfig.Metrics.Enabled {
go func() {
defer func() {
if r := recover(); r != nil {
a.logger.ErrorContext(
ctx, "panic while getting service metrics connection status",
"error", r,
"service", def.DefinitionMetadata.Id,
)
}
}()
defer wg.Done()
status, _ := a.getServiceMetricsConnectionStatus(ctx, cloudAccountID, orgID, def)
resp.Metrics = status
}()
}
if def.Strategy.AWSLogs != nil && serviceConfig.Logs.Enabled {
go func() {
defer func() {
if r := recover(); r != nil {
a.logger.ErrorContext(
ctx, "panic while getting service logs connection status",
"error", r,
"service", def.DefinitionMetadata.Id,
)
}
}()
defer wg.Done()
status, _ := a.getServiceLogsConnectionStatus(ctx, cloudAccountID, orgID, def)
resp.Logs = status
}()
}
wg.Wait()
return resp, nil
}
func (a *awsProvider) getServiceMetricsConnectionStatus(
ctx context.Context,
cloudAccountID string,
orgID string,
def *integrationstypes.AWSServiceDefinition,
) ([]*integrationstypes.SignalConnectionStatus, error) {
if def.Strategy == nil ||
len(def.Strategy.AWSMetrics.StreamFilters) < 1 ||
len(def.DataCollected.Metrics) < 1 {
return nil, nil
}
statusResp := make([]*integrationstypes.SignalConnectionStatus, 0)
for _, category := range def.IngestionStatusCheck.Metrics {
queries := make([]qbtypes.QueryEnvelope, 0)
for _, check := range category.Checks {
filterExpression := fmt.Sprintf(`cloud.provider="aws" AND cloud.account.id="%s"`, cloudAccountID)
f := ""
for _, attribute := range check.Attributes {
f = fmt.Sprintf("%s %s", attribute.Name, attribute.Operator)
if attribute.Value != "" {
f = fmt.Sprintf("%s '%s'", f, attribute.Value)
}
filterExpression = fmt.Sprintf("%s AND %s", filterExpression, f)
}
queries = append(queries, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: valuer.GenerateUUID().String(),
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{{
MetricName: check.Key,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
}},
Filter: &qbtypes.Filter{
Expression: filterExpression,
},
},
})
}
resp, err := a.querier.QueryRange(ctx, valuer.MustNewUUID(orgID), &qbtypes.QueryRangeRequest{
SchemaVersion: "v5",
Start: uint64(time.Now().Add(-time.Hour).UnixMilli()),
End: uint64(time.Now().UnixMilli()),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
})
if err != nil {
a.logger.DebugContext(ctx,
"error querying for service metrics connection status",
"error", err,
"service", def.DefinitionMetadata.Id,
)
continue
}
if resp != nil && len(resp.Data.Results) < 1 {
continue
}
queryResponse, ok := resp.Data.Results[0].(*qbtypes.TimeSeriesData)
if !ok {
continue
}
if queryResponse == nil ||
len(queryResponse.Aggregations) < 1 ||
len(queryResponse.Aggregations[0].Series) < 1 ||
len(queryResponse.Aggregations[0].Series[0].Values) < 1 {
continue
}
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
CategoryID: category.Category,
CategoryDisplayName: category.DisplayName,
LastReceivedTsMillis: queryResponse.Aggregations[0].Series[0].Values[0].Timestamp,
LastReceivedFrom: "signoz-aws-integration",
})
}
return statusResp, nil
}
func (a *awsProvider) getServiceLogsConnectionStatus(
ctx context.Context,
cloudAccountID string,
orgID string,
def *integrationstypes.AWSServiceDefinition,
) ([]*integrationstypes.SignalConnectionStatus, error) {
if def.Strategy == nil ||
len(def.Strategy.AWSLogs.Subscriptions) < 1 ||
len(def.DataCollected.Logs) < 1 {
return nil, nil
}
statusResp := make([]*integrationstypes.SignalConnectionStatus, 0)
for _, category := range def.IngestionStatusCheck.Logs {
queries := make([]qbtypes.QueryEnvelope, 0)
for _, check := range category.Checks {
filterExpression := fmt.Sprintf(`cloud.account.id="%s"`, cloudAccountID)
f := ""
for _, attribute := range check.Attributes {
f = fmt.Sprintf("%s %s", attribute.Name, attribute.Operator)
if attribute.Value != "" {
f = fmt.Sprintf("%s '%s'", f, attribute.Value)
}
filterExpression = fmt.Sprintf("%s AND %s", filterExpression, f)
}
queries = append(queries, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: valuer.GenerateUUID().String(),
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{
Expression: "count()",
}},
Filter: &qbtypes.Filter{
Expression: filterExpression,
},
Limit: 10,
Offset: 0,
},
})
}
resp, err := a.querier.QueryRange(ctx, valuer.MustNewUUID(orgID), &qbtypes.QueryRangeRequest{
SchemaVersion: "v1",
Start: uint64(time.Now().Add(-time.Hour * 1).UnixMilli()),
End: uint64(time.Now().UnixMilli()),
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
})
if err != nil {
a.logger.DebugContext(ctx,
"error querying for service logs connection status",
"error", err,
"service", def.DefinitionMetadata.Id,
)
continue
}
if resp != nil && len(resp.Data.Results) < 1 {
continue
}
queryResponse, ok := resp.Data.Results[0].(*qbtypes.TimeSeriesData)
if !ok {
continue
}
if queryResponse == nil ||
len(queryResponse.Aggregations) < 1 ||
len(queryResponse.Aggregations[0].Series) < 1 ||
len(queryResponse.Aggregations[0].Series[0].Values) < 1 {
continue
}
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
CategoryID: category.Category,
CategoryDisplayName: category.DisplayName,
LastReceivedTsMillis: queryResponse.Aggregations[0].Series[0].Values[0].Timestamp,
LastReceivedFrom: "signoz-aws-integration",
})
}
return statusResp, nil
}
func (a *awsProvider) getServiceConfig(ctx context.Context,
def *integrationstypes.AWSServiceDefinition, orgID, cloudProvider, serviceId, cloudAccountId string,
) (*integrationstypes.AWSCloudServiceConfig, error) {
activeAccount, err := a.accountsRepo.GetConnectedCloudAccount(ctx, orgID, cloudProvider, cloudAccountId)
if err != nil {
return nil, err
}
config, err := a.serviceConfigRepo.Get(ctx, orgID, activeAccount.ID.StringValue(), serviceId)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return nil, nil
}
return nil, err
}
serviceConfig := new(integrationstypes.AWSCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
return nil, err
}
if config != nil && serviceConfig.Metrics != nil && serviceConfig.Metrics.Enabled {
def.PopulateDashboardURLs(serviceId)
}
return serviceConfig, nil
}
func (a *awsProvider) GetAvailableDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
accountRecords, err := a.accountsRepo.ListConnected(ctx, orgID.StringValue(), a.GetName().String())
if err != nil {
return nil, err
}
// for now service dashboards are only available when metrics are enabled.
servicesWithAvailableMetrics := map[string]*time.Time{}
for _, ar := range accountRecords {
if ar.AccountID != nil {
configsBySvcId, err := a.serviceConfigRepo.GetAllForAccount(ctx, orgID.StringValue(), ar.ID.StringValue())
if err != nil {
return nil, err
}
for svcId, config := range configsBySvcId {
serviceConfig := new(integrationstypes.AWSCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
return nil, err
}
if serviceConfig.Metrics != nil && serviceConfig.Metrics.Enabled {
servicesWithAvailableMetrics[svcId] = &ar.CreatedAt
}
}
}
}
svcDashboards := make([]*dashboardtypes.Dashboard, 0)
allServices, err := a.awsServiceDefinitions.ListServiceDefinitions(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list aws service definitions")
}
for _, svc := range allServices {
serviceDashboardsCreatedAt, ok := servicesWithAvailableMetrics[svc.Id]
if ok {
svcDashboards = integrationstypes.GetDashboardsFromAssets(svc.Id, a.GetName(), serviceDashboardsCreatedAt, svc.Assets)
servicesWithAvailableMetrics[svc.Id] = nil
}
}
return svcDashboards, nil
}
func (a *awsProvider) GetDashboard(ctx context.Context, req *integrationstypes.GettableDashboard) (*dashboardtypes.Dashboard, error) {
allDashboards, err := a.GetAvailableDashboards(ctx, req.OrgID)
if err != nil {
return nil, err
}
for _, d := range allDashboards {
if d.ID == req.ID {
return d, nil
}
}
return nil, errors.NewNotFoundf(CodeDashboardNotFound, "dashboard with id %s not found", req.ID)
}
func (a *awsProvider) GenerateConnectionArtifact(ctx context.Context, req *integrationstypes.PostableConnectionArtifact) (any, error) {
connection := new(integrationstypes.PostableAWSConnectionUrl)
err := connection.Unmarshal(req.Data)
if err != nil {
return nil, err
}
if connection.AccountConfig != nil {
for _, region := range connection.AccountConfig.EnabledRegions {
if integrationstypes.ValidAWSRegions[region] {
continue
}
return nil, errors.NewInvalidInputf(CodeInvalidAWSRegion, "invalid aws region: %s", region)
}
}
config, err := connection.AccountConfig.Marshal()
if err != nil {
return nil, err
}
account, err := a.accountsRepo.Upsert(
ctx, req.OrgID, integrationstypes.CloudProviderAWS.String(), nil, config,
nil, nil, nil,
)
if err != nil {
return nil, err
}
agentVersion := "v0.0.8"
if connection.AgentConfig.Version != "" {
agentVersion = connection.AgentConfig.Version
}
baseURL := fmt.Sprintf("https://%s.console.aws.amazon.com/cloudformation/home",
connection.AgentConfig.Region)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", connection.AgentConfig.Region)
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", connection.AgentConfig.SigNozAPIUrl)
q.Set("param_SigNozApiKey", connection.AgentConfig.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", connection.AgentConfig.IngestionUrl)
q.Set("param_IngestionKey", connection.AgentConfig.IngestionKey)
return &integrationstypes.GettableAWSConnectionUrl{
AccountId: account.ID.StringValue(),
ConnectionUrl: u.String() + "?&" + q.Encode(), // this format is required by AWS
}, nil
}
func (a *awsProvider) UpdateServiceConfig(ctx context.Context, req *integrationstypes.PatchableServiceConfig) (any, error) {
definition, err := a.awsServiceDefinitions.GetServiceDefinition(ctx, req.ServiceId)
if err != nil {
return nil, model.InternalError(fmt.Errorf("couldn't get aws service definition: %w", err))
}
serviceConfig := new(integrationstypes.PatchableAWSCloudServiceConfig)
err = serviceConfig.Unmarshal(req.Config)
if err != nil {
return nil, err
}
if err = serviceConfig.Config.Validate(definition); err != nil {
return nil, err
}
// can only update config for a connected cloud account id
_, err = a.accountsRepo.GetConnectedCloudAccount(
ctx, req.OrgID, a.GetName().String(), serviceConfig.CloudAccountId,
)
if err != nil {
return nil, err
}
serviceConfigBytes, err := serviceConfig.Config.Marshal()
if err != nil {
return nil, err
}
updatedConfig, err := a.serviceConfigRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), serviceConfig.CloudAccountId, req.ServiceId, serviceConfigBytes,
)
if err != nil {
return nil, err
}
if err = serviceConfig.Unmarshal(updatedConfig); err != nil {
return nil, err
}
return &integrationstypes.PatchServiceConfigResponse{
ServiceId: req.ServiceId,
Config: serviceConfig,
}, nil
}
func (a *awsProvider) UpdateAccountConfig(ctx context.Context, req *integrationstypes.PatchableAccountConfig) (any, error) {
config := new(integrationstypes.PatchableAWSAccountConfig)
err := config.Unmarshal(req.Data)
if err != nil {
return nil, err
}
if config.Config == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "account config can't be null")
}
for _, region := range config.Config.EnabledRegions {
if integrationstypes.ValidAWSRegions[region] {
continue
}
return nil, errors.NewInvalidInputf(CodeInvalidAWSRegion, "invalid aws region: %s", region)
}
configBytes, err := config.Config.Marshal()
if err != nil {
return nil, err
}
// account must exist to update config, but it doesn't need to be connected
_, err = a.accountsRepo.Get(ctx, req.OrgID, a.GetName().String(), req.AccountId)
if err != nil {
return nil, err
}
accountRecord, err := a.accountsRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), &req.AccountId, configBytes, nil, nil, nil,
)
if err != nil {
return nil, err
}
return accountRecord.Account(a.GetName()), nil
}
func (a *awsProvider) DisconnectAccount(ctx context.Context, orgID, accountID string) (*integrationstypes.CloudIntegration, error) {
account, err := a.accountsRepo.Get(ctx, orgID, a.GetName().String(), accountID)
if err != nil {
return nil, err
}
tsNow := time.Now()
account, err = a.accountsRepo.Upsert(
ctx, orgID, a.GetName().String(), &accountID, nil, nil, nil, &tsNow,
)
if err != nil {
return nil, err
}
return account, nil
}

View File

@@ -0,0 +1,584 @@
package implazureprovider
import (
"context"
"fmt"
"log/slog"
"slices"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/store"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
var (
CodeInvalidAzureRegion = errors.MustNewCode("invalid_azure_region")
CodeDashboardNotFound = errors.MustNewCode("dashboard_not_found")
)
type azureProvider struct {
logger *slog.Logger
accountsRepo store.CloudProviderAccountsRepository
serviceConfigRepo store.ServiceConfigDatabase
azureServiceDefinitions *services.AzureServicesProvider
querier querier.Querier
}
func NewAzureCloudProvider(
logger *slog.Logger,
accountsRepo store.CloudProviderAccountsRepository,
serviceConfigRepo store.ServiceConfigDatabase,
querier querier.Querier,
) integrationstypes.CloudProvider {
azureServiceDefinitions, err := services.NewAzureCloudProviderServices()
if err != nil {
panic("failed to initialize Azure service definitions: " + err.Error())
}
return &azureProvider{
logger: logger,
accountsRepo: accountsRepo,
serviceConfigRepo: serviceConfigRepo,
azureServiceDefinitions: azureServiceDefinitions,
querier: querier,
}
}
func (a *azureProvider) GetAccountStatus(ctx context.Context, orgID, accountID string) (*integrationstypes.GettableAccountStatus, error) {
account, err := a.accountsRepo.Get(ctx, orgID, a.GetName().String(), accountID)
if err != nil {
return nil, err
}
return &integrationstypes.GettableAccountStatus{
Id: account.ID.String(),
CloudAccountId: account.AccountID,
Status: account.Status(),
}, nil
}
func (a *azureProvider) ListConnectedAccounts(ctx context.Context, orgID string) (*integrationstypes.GettableConnectedAccountsList, error) {
accountRecords, err := a.accountsRepo.ListConnected(ctx, orgID, a.GetName().String())
if err != nil {
return nil, err
}
connectedAccounts := make([]*integrationstypes.Account, 0, len(accountRecords))
for _, r := range accountRecords {
connectedAccounts = append(connectedAccounts, r.Account(a.GetName()))
}
return &integrationstypes.GettableConnectedAccountsList{
Accounts: connectedAccounts,
}, nil
}
func (a *azureProvider) AgentCheckIn(ctx context.Context, req *integrationstypes.PostableAgentCheckInPayload) (any, error) {
existingAccount, err := a.accountsRepo.Get(ctx, req.OrgID, a.GetName().String(), req.ID)
if err != nil {
return nil, err
}
if existingAccount != nil && existingAccount.AccountID != nil && *existingAccount.AccountID != req.AccountID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in with new %s account id %s for account %s with existing %s id %s",
a.GetName().String(), req.AccountID, existingAccount.ID.StringValue(), a.GetName().String(),
*existingAccount.AccountID,
))
}
existingAccount, err = a.accountsRepo.GetConnectedCloudAccount(ctx, req.OrgID, a.GetName().String(), req.AccountID)
if existingAccount != nil && existingAccount.ID.StringValue() != req.ID {
return nil, model.BadRequest(fmt.Errorf(
"can't check in to %s account %s with id %s. already connected with id %s",
a.GetName().String(), req.AccountID, req.ID, existingAccount.ID.StringValue(),
))
}
agentReport := integrationstypes.AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: req.Data,
}
account, err := a.accountsRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), &req.ID, nil, &req.AccountID, &agentReport, nil,
)
if err != nil {
return nil, err
}
agentConfig, err := a.getAzureAgentConfig(ctx, account)
if err != nil {
return nil, err
}
return &integrationstypes.GettableAzureAgentCheckIn{
AccountId: account.ID.StringValue(),
CloudAccountId: *account.AccountID,
RemovedAt: account.RemovedAt,
IntegrationConfig: *agentConfig,
}, nil
}
func (a *azureProvider) getAzureAgentConfig(ctx context.Context, account *integrationstypes.CloudIntegration) (*integrationstypes.AzureAgentIntegrationConfig, error) {
// prepare and return integration config to be consumed by agent
agentConfig := &integrationstypes.AzureAgentIntegrationConfig{
TelemetryCollectionStrategy: make(map[string]*integrationstypes.AzureCollectionStrategy),
}
accountConfig := new(integrationstypes.AzureAccountConfig)
err := accountConfig.Unmarshal(account.Config)
if err != nil {
return nil, err
}
if account.Config != nil {
agentConfig.DeploymentRegion = accountConfig.DeploymentRegion
agentConfig.EnabledResourceGroups = accountConfig.EnabledResourceGroups
}
svcConfigs, err := a.serviceConfigRepo.GetAllForAccount(
ctx, account.OrgID, account.ID.StringValue(),
)
if err != nil {
return nil, err
}
// accumulate config in a fixed order to ensure same config generated across runs
configuredServices := maps.Keys(svcConfigs)
slices.Sort(configuredServices)
metrics := make([]*integrationstypes.AzureMetricsStrategy, 0)
logs := make([]*integrationstypes.AzureLogsStrategy, 0)
for _, svcType := range configuredServices {
definition, err := a.azureServiceDefinitions.GetServiceDefinition(ctx, svcType)
if err != nil {
continue
}
config := svcConfigs[svcType]
serviceConfig := new(integrationstypes.AzureCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
continue
}
metricsStrategyMap := make(map[string]*integrationstypes.AzureMetricsStrategy)
logsStrategyMap := make(map[string]*integrationstypes.AzureLogsStrategy)
for _, metric := range definition.Strategy.AzureMetrics {
metricsStrategyMap[metric.Name] = metric
}
for _, log := range definition.Strategy.AzureLogs {
logsStrategyMap[log.Name] = log
}
if serviceConfig.Metrics != nil {
for _, metric := range serviceConfig.Metrics {
if metric.Enabled {
metrics = append(metrics, &integrationstypes.AzureMetricsStrategy{
CategoryType: metricsStrategyMap[metric.Name].CategoryType,
Name: metric.Name,
})
}
}
}
if serviceConfig.Logs != nil {
for _, log := range serviceConfig.Logs {
if log.Enabled {
logs = append(logs, &integrationstypes.AzureLogsStrategy{
CategoryType: logsStrategyMap[log.Name].CategoryType,
Name: log.Name,
})
}
}
}
strategy := integrationstypes.AzureCollectionStrategy{}
strategy.AzureMetrics = metrics
strategy.AzureLogs = logs
agentConfig.TelemetryCollectionStrategy[svcType] = &strategy
}
return agentConfig, nil
}
func (a *azureProvider) GetName() valuer.String {
return integrationstypes.CloudProviderAzure
}
func (a *azureProvider) ListServices(ctx context.Context, orgID string, cloudAccountID *string) (any, error) {
svcConfigs := make(map[string]*integrationstypes.AzureCloudServiceConfig)
if cloudAccountID != nil {
activeAccount, err := a.accountsRepo.GetConnectedCloudAccount(ctx, orgID, a.GetName().String(), *cloudAccountID)
if err != nil {
return nil, err
}
serviceConfigs, err := a.serviceConfigRepo.GetAllForAccount(ctx, orgID, activeAccount.ID.StringValue())
if err != nil {
return nil, err
}
for svcType, config := range serviceConfigs {
serviceConfig := new(integrationstypes.AzureCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
return nil, err
}
svcConfigs[svcType] = serviceConfig
}
}
summaries := make([]integrationstypes.AzureServiceSummary, 0)
definitions, err := a.azureServiceDefinitions.ListServiceDefinitions(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf("couldn't list aws service definitions: %w", err))
}
for _, def := range definitions {
summary := integrationstypes.AzureServiceSummary{
DefinitionMetadata: def.DefinitionMetadata,
Config: nil,
}
summary.Config = svcConfigs[summary.Id]
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].DefinitionMetadata.Title < summaries[j].DefinitionMetadata.Title
})
return &integrationstypes.GettableAzureServices{
Services: summaries,
}, nil
}
func (a *azureProvider) GetServiceDetails(ctx context.Context, req *integrationstypes.GetServiceDetailsReq) (any, error) {
details := new(integrationstypes.GettableAzureServiceDetails)
azureDefinition, err := a.azureServiceDefinitions.GetServiceDefinition(ctx, req.ServiceId)
if err != nil {
return nil, model.InternalError(fmt.Errorf("couldn't get aws service definition: %w", err))
}
details.AzureServiceDefinition = *azureDefinition
if req.CloudAccountID == nil {
return details, nil
}
config, err := a.getServiceConfig(ctx, &details.AzureServiceDefinition, req.OrgID, req.ServiceId, *req.CloudAccountID)
if err != nil {
return nil, err
}
details.Config = config
// fill default values for config
if details.Config == nil {
cfg := new(integrationstypes.AzureCloudServiceConfig)
logs := make([]*integrationstypes.AzureCloudServiceLogsConfig, 0)
for _, log := range azureDefinition.Strategy.AzureLogs {
logs = append(logs, &integrationstypes.AzureCloudServiceLogsConfig{
Enabled: false,
Name: log.Name,
})
}
metrics := make([]*integrationstypes.AzureCloudServiceMetricsConfig, 0)
for _, metric := range azureDefinition.Strategy.AzureMetrics {
metrics = append(metrics, &integrationstypes.AzureCloudServiceMetricsConfig{
Enabled: false,
Name: metric.Name,
})
}
cfg.Logs = logs
cfg.Metrics = metrics
details.Config = cfg
}
// TODO: write logic for getting connection status
return details, nil
}
func (a *azureProvider) getServiceConfig(
ctx context.Context,
definition *integrationstypes.AzureServiceDefinition,
orgID string,
serviceId string,
cloudAccountId string,
) (*integrationstypes.AzureCloudServiceConfig, error) {
activeAccount, err := a.accountsRepo.GetConnectedCloudAccount(ctx, orgID, a.GetName().String(), cloudAccountId)
if err != nil {
return nil, err
}
configBytes, err := a.serviceConfigRepo.Get(ctx, orgID, activeAccount.ID.String(), serviceId)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return nil, nil
}
return nil, err
}
config := new(integrationstypes.AzureCloudServiceConfig)
err = config.Unmarshal(configBytes)
if err != nil {
return nil, err
}
for _, metric := range config.Metrics {
if metric.Enabled {
definition.PopulateDashboardURLs(serviceId)
break
}
}
return config, nil
}
func (a *azureProvider) GenerateConnectionArtifact(ctx context.Context, req *integrationstypes.PostableConnectionArtifact) (any, error) {
connection := new(integrationstypes.PostableAzureConnectionCommand)
err := connection.Unmarshal(req.Data)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed unmarshal request data into AWS connection config")
}
// validate connection config
if connection.AccountConfig != nil {
if !integrationstypes.ValidAzureRegions[connection.AccountConfig.DeploymentRegion] {
return nil, errors.NewInvalidInputf(CodeInvalidAzureRegion, "invalid azure region: %s",
connection.AccountConfig.DeploymentRegion,
)
}
}
config, err := connection.AccountConfig.Marshal()
if err != nil {
return nil, err
}
account, err := a.accountsRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), nil, config,
nil, nil, nil,
)
if err != nil {
return nil, err
}
agentVersion := "v0.0.1"
if connection.AgentConfig.Version != "" {
agentVersion = connection.AgentConfig.Version
}
// TODO: improve the command and set url
cliCommand := []string{"az", "stack", "sub", "create", "--name", "SigNozIntegration", "--location",
connection.AccountConfig.DeploymentRegion, "--template-uri", fmt.Sprintf("<url>%s", agentVersion),
"--action-on-unmanage", "deleteAll", "--deny-settings-mode", "denyDelete", "--parameters", fmt.Sprintf("rgName=%s", "signoz-integration-rg"),
fmt.Sprintf("rgLocation=%s", connection.AccountConfig.DeploymentRegion)}
return &integrationstypes.GettableAzureConnectionCommand{
AccountId: account.ID.String(),
AzureShellConnectionCommand: "az create",
AzureCliConnectionCommand: strings.Join(cliCommand, " "),
}, nil
}
func (a *azureProvider) UpdateServiceConfig(ctx context.Context, req *integrationstypes.PatchableServiceConfig) (any, error) {
definition, err := a.azureServiceDefinitions.GetServiceDefinition(ctx, req.ServiceId)
if err != nil {
return nil, err
}
serviceConfig := new(integrationstypes.PatchableAzureCloudServiceConfig)
err = serviceConfig.Unmarshal(req.Config)
if err != nil {
return nil, err
}
if err = serviceConfig.Config.Validate(definition); err != nil {
return nil, err
}
// can only update config for a connected cloud account id
_, err = a.accountsRepo.GetConnectedCloudAccount(
ctx, req.OrgID, a.GetName().String(), serviceConfig.CloudAccountId,
)
if err != nil {
return nil, err
}
serviceConfigBytes, err := serviceConfig.Config.Marshal()
if err != nil {
return nil, err
}
updatedConfig, err := a.serviceConfigRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), serviceConfig.CloudAccountId, req.ServiceId, serviceConfigBytes,
)
if err != nil {
return nil, err
}
if err = serviceConfig.Unmarshal(updatedConfig); err != nil {
return nil, err
}
return &integrationstypes.PatchServiceConfigResponse{
ServiceId: req.ServiceId,
Config: serviceConfig,
}, nil
}
func (a *azureProvider) GetAvailableDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
accountRecords, err := a.accountsRepo.ListConnected(ctx, orgID.StringValue(), a.GetName().String())
if err != nil {
return nil, err
}
// for now service dashboards are only available when metrics are enabled.
servicesWithAvailableMetrics := map[string]*time.Time{}
for _, ar := range accountRecords {
if ar.AccountID == nil {
continue
}
configsBySvcId, err := a.serviceConfigRepo.GetAllForAccount(ctx, orgID.StringValue(), ar.ID.StringValue())
if err != nil {
return nil, err
}
for svcId, config := range configsBySvcId {
serviceConfig := new(integrationstypes.AzureCloudServiceConfig)
err = serviceConfig.Unmarshal(config)
if err != nil {
return nil, err
}
if serviceConfig.Metrics != nil {
for _, metric := range serviceConfig.Metrics {
if metric.Enabled {
servicesWithAvailableMetrics[svcId] = &ar.CreatedAt
break
}
}
}
}
}
svcDashboards := make([]*dashboardtypes.Dashboard, 0)
allServices, err := a.azureServiceDefinitions.ListServiceDefinitions(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list azure service definitions")
}
for _, svc := range allServices {
serviceDashboardsCreatedAt := servicesWithAvailableMetrics[svc.Id]
if serviceDashboardsCreatedAt != nil {
svcDashboards = integrationstypes.GetDashboardsFromAssets(svc.Id, a.GetName(), serviceDashboardsCreatedAt, svc.Assets)
servicesWithAvailableMetrics[svc.Id] = nil
}
}
return svcDashboards, nil
}
func (a *azureProvider) GetDashboard(ctx context.Context, req *integrationstypes.GettableDashboard) (*dashboardtypes.Dashboard, error) {
allDashboards, err := a.GetAvailableDashboards(ctx, req.OrgID)
if err != nil {
return nil, err
}
for _, dashboard := range allDashboards {
if dashboard.ID == req.ID {
return dashboard, nil
}
}
return nil, errors.NewNotFoundf(CodeDashboardNotFound, "dashboard with id %s not found", req.ID)
}
func (a *azureProvider) UpdateAccountConfig(ctx context.Context, req *integrationstypes.PatchableAccountConfig) (any, error) {
config := new(integrationstypes.PatchableAzureAccountConfig)
err := config.Unmarshal(req.Data)
if err != nil {
return nil, err
}
if config.Config == nil && len(config.Config.EnabledResourceGroups) < 1 {
return nil, errors.NewInvalidInputf(CodeInvalidAzureRegion, "azure region and resource groups must be provided")
}
//for azure, preserve deployment region if already set
account, err := a.accountsRepo.Get(ctx, req.OrgID, a.GetName().String(), req.AccountId)
if err != nil {
return nil, err
}
storedConfig := new(integrationstypes.AzureAccountConfig)
err = storedConfig.Unmarshal(account.Config)
if err != nil {
return nil, err
}
if account.Config != nil {
config.Config.DeploymentRegion = storedConfig.DeploymentRegion
}
configBytes, err := config.Config.Marshal()
if err != nil {
return nil, err
}
accountRecord, err := a.accountsRepo.Upsert(
ctx, req.OrgID, a.GetName().String(), &req.AccountId, configBytes, nil, nil, nil,
)
if err != nil {
return nil, err
}
return accountRecord.Account(a.GetName()), nil
}
func (a *azureProvider) DisconnectAccount(ctx context.Context, orgID, accountID string) (*integrationstypes.CloudIntegration, error) {
account, err := a.accountsRepo.Get(ctx, orgID, a.GetName().String(), accountID)
if err != nil {
return nil, err
}
tsNow := time.Now()
account, err = a.accountsRepo.Upsert(
ctx, orgID, a.GetName().String(), &accountID, nil, nil, nil, &tsNow,
)
if err != nil {
return nil, err
}
return account, nil
}

View File

@@ -1,94 +1 @@
package cloudintegrations
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/types"
)
type ServiceSummary struct {
services.Metadata
Config *types.CloudServiceConfig `json:"config"`
}
type ServiceDetails struct {
services.Definition
Config *types.CloudServiceConfig `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
}
type AccountStatus struct {
Integration AccountIntegrationStatus `json:"integration"`
}
type AccountIntegrationStatus struct {
LastHeartbeatTsMillis *int64 `json:"last_heartbeat_ts_ms"`
}
type LogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type MetricsConfig struct {
Enabled bool `json:"enabled"`
}
type ServiceConnectionStatus struct {
Logs *SignalConnectionStatus `json:"logs"`
Metrics *SignalConnectionStatus `json:"metrics"`
}
type SignalConnectionStatus struct {
LastReceivedTsMillis int64 `json:"last_received_ts_ms"` // epoch milliseconds
LastReceivedFrom string `json:"last_received_from"` // resource identifier
}
type CompiledCollectionStrategy = services.CollectionStrategy
func NewCompiledCollectionStrategy(provider string) (*CompiledCollectionStrategy, error) {
if provider == "aws" {
return &CompiledCollectionStrategy{
Provider: "aws",
AWSMetrics: &services.AWSMetricsStrategy{},
AWSLogs: &services.AWSLogsStrategy{},
}, nil
}
return nil, errors.NewNotFoundf(services.CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", provider)
}
// Helper for accumulating strategies for enabled services.
func AddServiceStrategy(serviceType string, cs *CompiledCollectionStrategy,
definitionStrat *services.CollectionStrategy, config *types.CloudServiceConfig) error {
if definitionStrat.Provider != cs.Provider {
return errors.NewInternalf(CodeMismatchCloudProvider, "can't add %s service strategy to compiled strategy for %s",
definitionStrat.Provider, cs.Provider)
}
if cs.Provider == "aws" {
if config.Logs != nil && config.Logs.Enabled {
if serviceType == services.S3Sync {
// S3 bucket sync; No cloudwatch logs are appended for this service type;
// Though definition is populated with a custom cloudwatch group that helps in calculating logs connection status
cs.S3Buckets = config.Logs.S3Buckets
} else if definitionStrat.AWSLogs != nil { // services that includes a logs subscription
cs.AWSLogs.Subscriptions = append(
cs.AWSLogs.Subscriptions,
definitionStrat.AWSLogs.Subscriptions...,
)
}
}
if config.Metrics != nil && config.Metrics.Enabled && definitionStrat.AWSMetrics != nil {
cs.AWSMetrics.StreamFilters = append(
cs.AWSMetrics.StreamFilters,
definitionStrat.AWSMetrics.StreamFilters...,
)
}
return nil
}
return errors.NewNotFoundf(services.CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", cs.Provider)
}

View File

@@ -0,0 +1,26 @@
package cloudintegrations
import (
"log/slog"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/implawsprovider"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/implazureprovider"
integrationstore "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/store"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
)
func NewCloudProviderRegistry(logger *slog.Logger, store sqlstore.SQLStore, querier querier.Querier) map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider {
registry := make(map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider)
accountsRepo := integrationstore.NewCloudProviderAccountsRepository(store)
serviceConfigRepo := integrationstore.NewServiceConfigRepository(store)
awsProviderImpl := implawsprovider.NewAWSCloudProvider(logger, accountsRepo, serviceConfigRepo, querier)
registry[integrationstypes.CloudProviderAWS] = awsProviderImpl
azureProviderImpl := implazureprovider.NewAzureCloudProvider(logger, accountsRepo, serviceConfigRepo, querier)
registry[integrationstypes.CloudProviderAzure] = azureProviderImpl
return registry
}

View File

@@ -7,6 +7,24 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_ApplicationELB_ConsumedLCUs_count",
"attributes": []
},
{
"key": "aws_ApplicationELB_ProcessedBytes_sum",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{

View File

@@ -7,6 +7,75 @@
"metrics": true,
"logs": true
},
"ingestion_status_check": {
"metrics": [
{
"category": "rest_api",
"display_name": "REST API Metrics",
"checks": [
{
"key": "aws_ApiGateway_Count_count",
"attributes": [
{
"name": "ApiName",
"operator": "EXISTS",
"value": ""
}
]
}
]
},
{
"category": "http_api",
"display_name": "HTTP API Metrics",
"checks": [
{
"key": "aws_ApiGateway_Count_count",
"attributes": [
{
"name": "ApiId",
"operator": "EXISTS",
"value": ""
}
]
}
]
},
{
"category": "websocket_api",
"display_name": "Websocket API Metrics",
"checks": [
{
"key": "aws_ApiGateway_Count_count",
"attributes": [
{
"name": "ApiId",
"operator": "EXISTS",
"value": ""
}
]
}
]
}
],
"logs": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"attributes": [
{
"name": "aws.cloudwatch.log_group_name",
"operator": "ILIKE",
"value": "API-Gateway%"
}
]
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -148,6 +217,146 @@
"name": "aws_ApiGateway_Latency_sum",
"unit": "Milliseconds",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4xx_sum",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4xx_max",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4xx_min",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_4xx_count",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5xx_sum",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5xx_max",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5xx_min",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_5xx_count",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_DataProcessed_sum",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_DataProcessed_max",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_DataProcessed_min",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_DataProcessed_count",
"unit": "Bytes",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ExecutionError_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ExecutionError_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ExecutionError_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ExecutionError_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ClientError_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ClientError_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ClientError_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ClientError_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationError_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationError_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationError_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationError_count",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ConnectCount_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ConnectCount_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ConnectCount_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_ConnectCount_count",
"unit": "Count",
"type": "Gauge"
}
],
"logs": [

View File

@@ -7,6 +7,24 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_DynamoDB_AccountMaxReads_max",
"attributes": []
},
{
"key": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_max",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -391,4 +409,4 @@
}
]
}
}
}

View File

@@ -7,6 +7,24 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_EC2_CPUUtilization_max",
"attributes": []
},
{
"key": "aws_EC2_NetworkIn_max",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -515,4 +533,4 @@
}
]
}
}
}

View File

@@ -7,6 +7,81 @@
"metrics": true,
"logs": true
},
"ingestion_status_check": {
"metrics": [
{
"category": "overview",
"display_name": "Overview",
"checks": [
{
"key": "aws_ECS_CPUUtilization_max",
"attributes": []
},
{
"key": "aws_ECS_MemoryUtilization_max",
"attributes": []
}
]
},
{
"category": "containerinsights",
"display_name": "Container Insights",
"checks": [
{
"key": "aws_ECS_ContainerInsights_NetworkRxBytes_max",
"attributes": []
},
{
"key": "aws_ECS_ContainerInsights_StorageReadBytes_max",
"attributes": []
}
]
},
{
"category": "enhanced_containerinsights",
"display_name": "Enhanced Container Insights",
"checks": [
{
"key": "aws_ECS_ContainerInsights_ContainerCpuUtilization_max",
"attributes": [
{
"name": "TaskId",
"operator": "EXISTS",
"value": ""
}
]
},
{
"key": "aws_ECS_ContainerInsights_TaskMemoryUtilization_max",
"attributes": [
{
"name": "TaskId",
"operator": "EXISTS",
"value": ""
}
]
}
]
}
],
"logs": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"attributes": [
{
"name": "aws.cloudwatch.log_group_name",
"operator": "ILIKE",
"value": "%/ecs/%"
}
]
}
]
}
]
},
"data_collected": {
"metrics": [
{

View File

@@ -7,6 +7,20 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_ElastiCache_CacheHitRate_max",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics":[
{
@@ -1928,7 +1942,7 @@
"unit": "Percent",
"type": "Gauge",
"description": ""
}
}
]
},
"telemetry_collection_strategy": {
@@ -1951,4 +1965,4 @@
}
]
}
}
}

View File

@@ -7,6 +7,37 @@
"metrics": true,
"logs": true
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_Lambda_Invocations_sum",
"attributes": []
}
]
}
],
"logs": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"attributes": [
{
"name": "aws.cloudwatch.log_group_name",
"operator": "ILIKE",
"value": "/aws/lambda%"
}
]
}
]
}
]
},
"data_collected": {
"metrics": [
{

View File

@@ -7,6 +7,20 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_Kafka_KafkaDataLogsDiskUsed_max",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -1088,4 +1102,3 @@
]
}
}

View File

@@ -7,6 +7,37 @@
"metrics": true,
"logs": true
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_RDS_CPUUtilization_max",
"attributes": []
}
]
}
],
"logs": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"attributes": [
{
"name": "resources.aws.cloudwatch.log_group_name",
"operator": "ILIKE",
"value": "/aws/rds%"
}
]
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -800,4 +831,4 @@
}
]
}
}
}

View File

@@ -7,6 +7,20 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_SNS_NumberOfMessagesPublished_sum",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -127,4 +141,4 @@
}
]
}
}
}

View File

@@ -7,6 +7,24 @@
"metrics": true,
"logs": false
},
"ingestion_status_check": {
"metrics": [
{
"category": "$default",
"display_name": "Default",
"checks": [
{
"key": "aws_SQS_SentMessageSize_max",
"attributes": []
},
{
"key": "aws_SQS_NumberOfMessagesSent_sum",
"attributes": []
}
]
}
]
},
"data_collected": {
"metrics": [
{
@@ -247,4 +265,4 @@
}
]
}
}
}

View File

@@ -0,0 +1 @@
<svg id="f2f04349-8aee-4413-84c9-a9053611b319" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><defs><linearGradient id="ad4c4f96-09aa-4f91-ba10-5cb8ad530f74" x1="9" y1="15.83" x2="9" y2="5.79" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b3b3b3" /><stop offset="0.26" stop-color="#c1c1c1" /><stop offset="1" stop-color="#e6e6e6" /></linearGradient></defs><title>Icon-storage-86</title><path d="M.5,5.79h17a0,0,0,0,1,0,0v9.48a.57.57,0,0,1-.57.57H1.07a.57.57,0,0,1-.57-.57V5.79A0,0,0,0,1,.5,5.79Z" fill="url(#ad4c4f96-09aa-4f91-ba10-5cb8ad530f74)" /><path d="M1.07,2.17H16.93a.57.57,0,0,1,.57.57V5.79a0,0,0,0,1,0,0H.5a0,0,0,0,1,0,0V2.73A.57.57,0,0,1,1.07,2.17Z" fill="#37c2b1" /><path d="M2.81,6.89H15.18a.27.27,0,0,1,.26.27v1.4a.27.27,0,0,1-.26.27H2.81a.27.27,0,0,1-.26-.27V7.16A.27.27,0,0,1,2.81,6.89Z" fill="#fff" /><path d="M2.82,9.68H15.19a.27.27,0,0,1,.26.27v1.41a.27.27,0,0,1-.26.27H2.82a.27.27,0,0,1-.26-.27V10A.27.27,0,0,1,2.82,9.68Z" fill="#37c2b1" /><path d="M2.82,12.5H15.19a.27.27,0,0,1,.26.27v1.41a.27.27,0,0,1-.26.27H2.82a.27.27,0,0,1-.26-.27V12.77A.27.27,0,0,1,2.82,12.5Z" fill="#258277" /></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,252 @@
{
"id": "blobstorage",
"title": "Blob Storage",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": true
},
"data_collected": {
"metrics": [
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
},
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
},
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
},
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
}
]
},
"telemetry_collection_strategy": {
"azure_metrics": [
{
"category_type": "metrics",
"name": "Capacity"
},
{
"category_type": "metrics",
"name": "Transaction"
}
],
"azure_logs": [
{
"category_type": "logs",
"name": "StorageRead"
},
{
"category_type": "logs",
"name": "StorageWrite"
},
{
"category_type": "logs",
"name": "StorageDelete"
}
]
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Blob Storage Overview",
"description": "Overview of Blob Storage",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,2 @@
Monitor Azure Blob Storage with SigNoz
Collect key Blob Storage metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +1 @@
<svg id="f2f04349-8aee-4413-84c9-a9053611b319" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><defs><linearGradient id="ad4c4f96-09aa-4f91-ba10-5cb8ad530f74" x1="9" y1="15.83" x2="9" y2="5.79" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b3b3b3" /><stop offset="0.26" stop-color="#c1c1c1" /><stop offset="1" stop-color="#e6e6e6" /></linearGradient></defs><title>Icon-storage-86</title><path d="M.5,5.79h17a0,0,0,0,1,0,0v9.48a.57.57,0,0,1-.57.57H1.07a.57.57,0,0,1-.57-.57V5.79A0,0,0,0,1,.5,5.79Z" fill="url(#ad4c4f96-09aa-4f91-ba10-5cb8ad530f74)" /><path d="M1.07,2.17H16.93a.57.57,0,0,1,.57.57V5.79a0,0,0,0,1,0,0H.5a0,0,0,0,1,0,0V2.73A.57.57,0,0,1,1.07,2.17Z" fill="#37c2b1" /><path d="M2.81,6.89H15.18a.27.27,0,0,1,.26.27v1.4a.27.27,0,0,1-.26.27H2.81a.27.27,0,0,1-.26-.27V7.16A.27.27,0,0,1,2.81,6.89Z" fill="#fff" /><path d="M2.82,9.68H15.19a.27.27,0,0,1,.26.27v1.41a.27.27,0,0,1-.26.27H2.82a.27.27,0,0,1-.26-.27V10A.27.27,0,0,1,2.82,9.68Z" fill="#37c2b1" /><path d="M2.82,12.5H15.19a.27.27,0,0,1,.26.27v1.41a.27.27,0,0,1-.26.27H2.82a.27.27,0,0,1-.26-.27V12.77A.27.27,0,0,1,2.82,12.5Z" fill="#258277" /></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,247 @@
{
"id": "frontdoor",
"title": "Front Door",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supported_signals": {
"metrics": true,
"logs": true
},
"data_collected": {
"metrics": [
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "placeholder_metric_1",
"unit": "Percent",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
},
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
},
{
"name": "placeholder_log_1",
"path": "placeholder.path.value",
"type": "string"
}
]
},
"telemetry_collection_strategy": {
"azure_metrics": [
{
"category_type": "metrics",
"name": "Capacity"
},
{
"category_type": "metrics",
"name": "Transaction"
}
],
"azure_logs": [
{
"category_type": "logs",
"name": "StorageRead"
},
{
"category_type": "logs",
"name": "StorageWrite"
},
{
"category_type": "logs",
"name": "StorageDelete"
}
]
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Front Door Overview",
"description": "Overview of Blob Storage",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,2 @@
Monitor Azure Front Door with SigNoz
Collect key Front Door metrics and view them with an out of the box dashboard.

View File

@@ -1,91 +1 @@
package services
import (
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
type Metadata struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
type Definition struct {
Metadata
Overview string `json:"overview"` // markdown
Assets Assets `json:"assets"`
SupportedSignals SupportedSignals `json:"supported_signals"`
DataCollected DataCollected `json:"data_collected"`
Strategy *CollectionStrategy `json:"telemetry_collection_strategy"`
}
type Assets struct {
Dashboards []Dashboard `json:"dashboards"`
}
type SupportedSignals struct {
Logs bool `json:"logs"`
Metrics bool `json:"metrics"`
}
type DataCollected struct {
Logs []CollectedLogAttribute `json:"logs"`
Metrics []CollectedMetric `json:"metrics"`
}
type CollectedLogAttribute struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
type CollectedMetric struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit"`
Description string `json:"description"`
}
type CollectionStrategy struct {
Provider string `json:"provider"`
AWSMetrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
AWSLogs *AWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type
}
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 {
// json tags here are in the shape expected by AWS API as detailed at
// 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"`
}
type AWSLogsStrategy struct {
Subscriptions []struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"log_group_name_prefix"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required.
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
type Dashboard struct {
Id string `json:"id"`
Url string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Definition *dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}

View File

@@ -2,128 +2,128 @@ package services
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"path"
"sort"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
koanfJson "github.com/knadh/koanf/parsers/json"
"golang.org/x/exp/maps"
)
const (
S3Sync = "s3sync"
)
var (
CodeUnsupportedCloudProvider = errors.MustNewCode("unsupported_cloud_provider")
CodeUnsupportedServiceType = errors.MustNewCode("unsupported_service_type")
CodeServiceDefinitionNotFound = errors.MustNewCode("service_definition_not_dound")
)
func List(cloudProvider string) ([]Definition, *model.ApiError) {
cloudServices, found := supportedServices[cloudProvider]
if !found || cloudServices == nil {
return nil, model.NotFoundError(fmt.Errorf(
"unsupported cloud provider: %s", cloudProvider,
))
type (
AWSServicesProvider struct {
definitions map[string]*integrationstypes.AWSServiceDefinition
}
AzureServicesProvider struct {
definitions map[string]*integrationstypes.AzureServiceDefinition
}
)
services := maps.Values(cloudServices)
sort.Slice(services, func(i, j int) bool {
return services[i].Id < services[j].Id
})
return services, nil
func (a *AzureServicesProvider) ListServiceDefinitions(ctx context.Context) (map[string]*integrationstypes.AzureServiceDefinition, error) {
return a.definitions, nil
}
func Map(cloudProvider string) (map[string]Definition, error) {
cloudServices, found := supportedServices[cloudProvider]
if !found || cloudServices == nil {
return nil, errors.Newf(errors.TypeNotFound, CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", cloudProvider)
func (a *AzureServicesProvider) GetServiceDefinition(ctx context.Context, serviceName string) (*integrationstypes.AzureServiceDefinition, error) {
def, ok := a.definitions[serviceName]
if !ok {
return nil, errors.NewNotFoundf(CodeServiceDefinitionNotFound, "azure service definition not found: %s", serviceName)
}
return def, nil
}
func (a *AWSServicesProvider) ListServiceDefinitions(ctx context.Context) (map[string]*integrationstypes.AWSServiceDefinition, error) {
return a.definitions, nil
}
func (a *AWSServicesProvider) GetServiceDefinition(ctx context.Context, serviceName string) (*integrationstypes.AWSServiceDefinition, error) {
def, ok := a.definitions[serviceName]
if !ok {
return nil, errors.NewNotFoundf(CodeServiceDefinitionNotFound, "aws service definition not found: %s", serviceName)
}
return def, nil
}
func NewAWSCloudProviderServices() (*AWSServicesProvider, error) {
definitions, err := readAllServiceDefinitions(integrationstypes.CloudProviderAWS)
if err != nil {
return nil, err
}
serviceDefinitions := make(map[string]*integrationstypes.AWSServiceDefinition)
for id, def := range definitions {
typedDef, ok := def.(*integrationstypes.AWSServiceDefinition)
if !ok {
return nil, fmt.Errorf("invalid type for AWS service definition %s", id)
}
serviceDefinitions[id] = typedDef
}
return &AWSServicesProvider{
definitions: serviceDefinitions,
}, nil
}
func NewAzureCloudProviderServices() (*AzureServicesProvider, error) {
definitions, err := readAllServiceDefinitions(integrationstypes.CloudProviderAzure)
if err != nil {
return nil, err
}
serviceDefinitions := make(map[string]*integrationstypes.AzureServiceDefinition)
for id, def := range definitions {
typedDef, ok := def.(*integrationstypes.AzureServiceDefinition)
if !ok {
return nil, fmt.Errorf("invalid type for Azure service definition %s", id)
}
serviceDefinitions[id] = typedDef
}
return &AzureServicesProvider{
definitions: serviceDefinitions,
}, nil
}
// End of API. Logic for reading service definition files follows
//go:embed definitions/*
var definitionFiles embed.FS
func readAllServiceDefinitions(cloudProvider valuer.String) (map[string]any, error) {
rootDirName := "definitions"
cloudProviderDirPath := path.Join(rootDirName, cloudProvider.String())
cloudServices, err := readServiceDefinitionsFromDir(cloudProvider, cloudProviderDirPath)
if err != nil {
return nil, err
}
if len(cloudServices) < 1 {
return nil, errors.NewInternalf(errors.CodeInternal, "no service definitions found in %s", cloudProviderDirPath)
}
return cloudServices, nil
}
func GetServiceDefinition(cloudProvider, serviceType string) (*Definition, error) {
cloudServices := supportedServices[cloudProvider]
if cloudServices == nil {
return nil, errors.Newf(errors.TypeNotFound, CodeUnsupportedCloudProvider, "unsupported cloud provider: %s", cloudProvider)
}
svc, exists := cloudServices[serviceType]
if !exists {
return nil, errors.Newf(errors.TypeNotFound, CodeUnsupportedServiceType, "%s service not found: %s", cloudProvider, serviceType)
}
return &svc, nil
}
// End of API. Logic for reading service definition files follows
// Service details read from ./serviceDefinitions
// { "providerName": { "service_id": {...}} }
var supportedServices map[string]map[string]Definition
func init() {
err := readAllServiceDefinitions()
if err != nil {
panic(fmt.Errorf(
"couldn't read cloud service definitions: %w", err,
))
}
}
//go:embed definitions/*
var definitionFiles embed.FS
func readAllServiceDefinitions() error {
supportedServices = map[string]map[string]Definition{}
rootDirName := "definitions"
cloudProviderDirs, err := fs.ReadDir(definitionFiles, rootDirName)
if err != nil {
return fmt.Errorf("couldn't read dirs in %s: %w", rootDirName, err)
}
for _, d := range cloudProviderDirs {
if !d.IsDir() {
continue
}
cloudProvider := d.Name()
cloudProviderDirPath := path.Join(rootDirName, cloudProvider)
cloudServices, err := readServiceDefinitionsFromDir(cloudProvider, cloudProviderDirPath)
if err != nil {
return fmt.Errorf("couldn't read %s service definitions: %w", cloudProvider, err)
}
if len(cloudServices) < 1 {
return fmt.Errorf("no %s services could be read", cloudProvider)
}
supportedServices[cloudProvider] = cloudServices
}
return nil
}
func readServiceDefinitionsFromDir(cloudProvider string, cloudProviderDirPath string) (
map[string]Definition, error,
) {
func readServiceDefinitionsFromDir(cloudProvider valuer.String, cloudProviderDirPath string) (map[string]any, error) {
svcDefDirs, err := fs.ReadDir(definitionFiles, cloudProviderDirPath)
if err != nil {
return nil, fmt.Errorf("couldn't list integrations dirs: %w", err)
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list integrations dirs")
}
svcDefs := map[string]Definition{}
svcDefs := make(map[string]any)
for _, d := range svcDefDirs {
if !d.IsDir() {
@@ -133,103 +133,73 @@ func readServiceDefinitionsFromDir(cloudProvider string, cloudProviderDirPath st
svcDirPath := path.Join(cloudProviderDirPath, d.Name())
s, err := readServiceDefinition(cloudProvider, svcDirPath)
if err != nil {
return nil, fmt.Errorf("couldn't read svc definition for %s: %w", d.Name(), err)
return nil, err
}
_, exists := svcDefs[s.Id]
_, exists := svcDefs[s.GetId()]
if exists {
return nil, fmt.Errorf(
"duplicate service definition for id %s at %s", s.Id, d.Name(),
)
return nil, errors.NewInternalf(errors.CodeInternal, "duplicate service definition for id %s at %s", s.GetId(), d.Name())
}
svcDefs[s.Id] = *s
svcDefs[s.GetId()] = s
}
return svcDefs, nil
}
func readServiceDefinition(cloudProvider string, svcDirpath string) (*Definition, error) {
func readServiceDefinition(cloudProvider valuer.String, svcDirpath string) (integrationstypes.Definition, error) {
integrationJsonPath := path.Join(svcDirpath, "integration.json")
serializedSpec, err := definitionFiles.ReadFile(integrationJsonPath)
if err != nil {
return nil, fmt.Errorf(
"couldn't find integration.json in %s: %w",
svcDirpath, err,
)
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read integration definition in %s", svcDirpath)
}
integrationSpec, err := koanfJson.Parser().Unmarshal(serializedSpec)
if err != nil {
return nil, fmt.Errorf(
"couldn't parse integration.json from %s: %w",
integrationJsonPath, err,
)
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse integration definition in %s", svcDirpath)
}
hydrated, err := integrations.HydrateFileUris(
integrationSpec, definitionFiles, svcDirpath,
)
hydrated, err := integrations.HydrateFileUris(integrationSpec, definitionFiles, svcDirpath)
if err != nil {
return nil, fmt.Errorf(
"couldn't hydrate files referenced in service definition %s: %w",
integrationJsonPath, err,
)
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't hydrate integration definition in %s", svcDirpath)
}
hydratedSpec := hydrated.(map[string]any)
serviceDef, err := ParseStructWithJsonTagsFromMap[Definition](hydratedSpec)
if err != nil {
return nil, fmt.Errorf(
"couldn't parse hydrated JSON spec read from %s: %w",
integrationJsonPath, err,
)
var serviceDef integrationstypes.Definition
switch cloudProvider {
case integrationstypes.CloudProviderAWS:
serviceDef = &integrationstypes.AWSServiceDefinition{}
case integrationstypes.CloudProviderAzure:
serviceDef = &integrationstypes.AzureServiceDefinition{}
default:
// ideally this shouldn't happen hence throwing internal error
return nil, errors.NewInternalf(errors.CodeInternal, "unsupported cloud provider: %s", cloudProvider)
}
err = validateServiceDefinition(serviceDef)
err = parseStructWithJsonTagsFromMap(hydratedSpec, serviceDef)
if err != nil {
return nil, fmt.Errorf("invalid service definition %s: %w", serviceDef.Id, err)
return nil, err
}
err = serviceDef.Validate()
if err != nil {
return nil, err
}
serviceDef.Strategy.Provider = cloudProvider
return serviceDef, nil
}
func validateServiceDefinition(s *Definition) error {
// Validate dashboard data
seenDashboardIds := map[string]interface{}{}
for _, dd := range s.Assets.Dashboards {
if _, seen := seenDashboardIds[dd.Id]; seen {
return fmt.Errorf("multiple dashboards found with id %s", dd.Id)
}
seenDashboardIds[dd.Id] = nil
}
if s.Strategy == nil {
return fmt.Errorf("telemetry_collection_strategy is required")
}
// potentially more to follow
return nil
}
func ParseStructWithJsonTagsFromMap[StructType any](data map[string]any) (
*StructType, error,
) {
func parseStructWithJsonTagsFromMap(data map[string]any, target interface{}) error {
mapJson, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("couldn't marshal map to json: %w", err)
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't marshal service definition json data")
}
var res StructType
decoder := json.NewDecoder(bytes.NewReader(mapJson))
decoder.DisallowUnknownFields()
err = decoder.Decode(&res)
err = decoder.Decode(target)
if err != nil {
return nil, fmt.Errorf("couldn't unmarshal json back to struct: %w", err)
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal service definition json data")
}
return &res, nil
return nil
}

View File

@@ -1,35 +1,3 @@
package services
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/stretchr/testify/require"
)
func TestAvailableServices(t *testing.T) {
require := require.New(t)
// should be able to list available services.
_, apiErr := List("bad-cloud-provider")
require.NotNil(apiErr)
require.Equal(model.ErrorNotFound, apiErr.Type())
awsSvcs, apiErr := List("aws")
require.Nil(apiErr)
require.Greater(len(awsSvcs), 0)
// should be able to get details of a service
_, err := GetServiceDefinition(
"aws", "bad-service-id",
)
require.NotNil(err)
require.True(errors.Ast(err, errors.TypeNotFound))
svc, err := GetServiceDefinition(
"aws", awsSvcs[0].Id,
)
require.Nil(err)
require.Equal(*svc, awsSvcs[0])
}
// TODO: add more tests for services package

View File

@@ -1,4 +1,4 @@
package cloudintegrations
package store
import (
"context"
@@ -7,49 +7,50 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type cloudProviderAccountsRepository interface {
listConnected(ctx context.Context, orgId string, provider string) ([]types.CloudIntegration, *model.ApiError)
var (
CodeCloudIntegrationAccountNotFound errors.Code = errors.MustNewCode("cloud_integration_account_not_found")
)
get(ctx context.Context, orgId string, provider string, id string) (*types.CloudIntegration, *model.ApiError)
type CloudProviderAccountsRepository interface {
ListConnected(ctx context.Context, orgId string, provider string) ([]integrationstypes.CloudIntegration, error)
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*types.CloudIntegration, *model.ApiError)
Get(ctx context.Context, orgId string, provider string, id string) (*integrationstypes.CloudIntegration, error)
GetConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationstypes.CloudIntegration, error)
// Insert an account or update it by (cloudProvider, id)
// for specified non-empty fields
upsert(
Upsert(
ctx context.Context,
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config []byte,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationstypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError)
) (*integrationstypes.CloudIntegration, error)
}
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
*cloudProviderAccountsSQLRepository, error,
) {
return &cloudProviderAccountsSQLRepository{
store: store,
}, nil
func NewCloudProviderAccountsRepository(store sqlstore.SQLStore) CloudProviderAccountsRepository {
return &cloudProviderAccountsSQLRepository{store: store}
}
type cloudProviderAccountsSQLRepository struct {
store sqlstore.SQLStore
}
func (r *cloudProviderAccountsSQLRepository) listConnected(
func (r *cloudProviderAccountsSQLRepository) ListConnected(
ctx context.Context, orgId string, cloudProvider string,
) ([]types.CloudIntegration, *model.ApiError) {
accounts := []types.CloudIntegration{}
) ([]integrationstypes.CloudIntegration, error) {
accounts := []integrationstypes.CloudIntegration{}
err := r.store.BunDB().NewSelect().
Model(&accounts).
@@ -62,18 +63,16 @@ func (r *cloudProviderAccountsSQLRepository) listConnected(
Scan(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not query connected cloud accounts: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "could not query connected cloud accounts")
}
return accounts, nil
}
func (r *cloudProviderAccountsSQLRepository) get(
func (r *cloudProviderAccountsSQLRepository) Get(
ctx context.Context, orgId string, provider string, id string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationstypes.CloudIntegration, error) {
var result integrationstypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -82,23 +81,25 @@ func (r *cloudProviderAccountsSQLRepository) get(
Where("id = ?", id).
Scan(ctx)
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find account with Id %s", id,
))
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud provider accounts: %w", err,
))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.WrapNotFoundf(
err,
CodeCloudIntegrationAccountNotFound,
"couldn't find account with Id %s", id,
)
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
}
return &result, nil
}
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
func (r *cloudProviderAccountsSQLRepository) GetConnectedCloudAccount(
ctx context.Context, orgId string, provider string, accountId string,
) (*types.CloudIntegration, *model.ApiError) {
var result types.CloudIntegration
) (*integrationstypes.CloudIntegration, error) {
var result integrationstypes.CloudIntegration
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -109,29 +110,25 @@ func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
Where("removed_at is NULL").
Scan(ctx)
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find connected cloud account %s", accountId,
))
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.WrapNotFoundf(err, CodeCloudIntegrationAccountNotFound, "couldn't find connected cloud account %s", accountId)
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud provider accounts: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
}
return &result, nil
}
func (r *cloudProviderAccountsSQLRepository) upsert(
func (r *cloudProviderAccountsSQLRepository) Upsert(
ctx context.Context,
orgId string,
provider string,
id *string,
config *types.AccountConfig,
config []byte,
accountId *string,
agentReport *types.AgentReport,
agentReport *integrationstypes.AgentReport,
removedAt *time.Time,
) (*types.CloudIntegration, *model.ApiError) {
) (*integrationstypes.CloudIntegration, error) {
// Insert
if id == nil {
temp := valuer.GenerateUUID().StringValue()
@@ -181,7 +178,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
)
}
integration := types.CloudIntegration{
integration := integrationstypes.CloudIntegration{
OrgID: orgId,
Provider: provider,
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},
@@ -195,22 +192,18 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
RemovedAt: removedAt,
}
_, dbErr := r.store.BunDB().NewInsert().
_, err := r.store.BunDB().NewInsert().
Model(&integration).
On(onConflictClause).
Exec(ctx)
if dbErr != nil {
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud account record: %w", dbErr,
))
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't upsert cloud integration account")
}
upsertedAccount, apiErr := r.get(ctx, orgId, provider, *id)
if apiErr != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't fetch upserted account by id: %w", apiErr.ToError(),
))
upsertedAccount, err := r.Get(ctx, orgId, provider, *id)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't get upserted cloud integration account")
}
return upsertedAccount, nil

View File

@@ -1,64 +1,63 @@
package cloudintegrations
package store
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
CodeServiceConfigNotFound = errors.MustNewCode("service_config_not_found")
)
type ServiceConfigDatabase interface {
get(
Get(
ctx context.Context,
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError)
) ([]byte, error)
upsert(
Upsert(
ctx context.Context,
orgID string,
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError)
config []byte,
) ([]byte, error)
getAllForAccount(
GetAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (
configsBySvcId map[string]*types.CloudServiceConfig,
apiErr *model.ApiError,
map[string][]byte,
error,
)
}
func newServiceConfigRepository(store sqlstore.SQLStore) (
*serviceConfigSQLRepository, error,
) {
return &serviceConfigSQLRepository{
store: store,
}, nil
func NewServiceConfigRepository(store sqlstore.SQLStore) ServiceConfigDatabase {
return &serviceConfigSQLRepository{store: store}
}
type serviceConfigSQLRepository struct {
store sqlstore.SQLStore
}
func (r *serviceConfigSQLRepository) get(
func (r *serviceConfigSQLRepository) Get(
ctx context.Context,
orgID string,
cloudAccountId string,
serviceType string,
) (*types.CloudServiceConfig, *model.ApiError) {
var result types.CloudIntegrationService
) ([]byte, error) {
var result integrationstypes.CloudIntegrationService
err := r.store.BunDB().NewSelect().
Model(&result).
@@ -67,36 +66,30 @@ func (r *serviceConfigSQLRepository) get(
Where("ci.id = ?", cloudAccountId).
Where("cis.type = ?", serviceType).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.WrapNotFoundf(err, CodeServiceConfigNotFound, "couldn't find config for cloud account %s", cloudAccountId)
}
if err == sql.ErrNoRows {
return nil, model.NotFoundError(fmt.Errorf(
"couldn't find config for cloud account %s",
cloudAccountId,
))
} else if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud service config: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud service config")
}
return &result.Config, nil
return result.Config, nil
}
func (r *serviceConfigSQLRepository) upsert(
func (r *serviceConfigSQLRepository) Upsert(
ctx context.Context,
orgID string,
cloudProvider string,
cloudAccountId string,
serviceId string,
config types.CloudServiceConfig,
) (*types.CloudServiceConfig, *model.ApiError) {
config []byte,
) ([]byte, error) {
// get cloud integration id from account id
// if the account is not connected, we don't need to upsert the config
var cloudIntegrationId string
err := r.store.BunDB().NewSelect().
Model((*types.CloudIntegration)(nil)).
Model((*integrationstypes.CloudIntegration)(nil)).
Column("id").
Where("provider = ?", cloudProvider).
Where("account_id = ?", cloudAccountId).
@@ -104,14 +97,18 @@ func (r *serviceConfigSQLRepository) upsert(
Where("removed_at is NULL").
Where("last_agent_report is not NULL").
Scan(ctx, &cloudIntegrationId)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"couldn't query cloud integration id: %w", err,
))
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.WrapNotFoundf(
err,
CodeCloudIntegrationAccountNotFound,
"couldn't find active cloud integration account",
)
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud integration id")
}
serviceConfig := types.CloudIntegrationService{
serviceConfig := integrationstypes.CloudIntegrationService{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
@@ -126,21 +123,18 @@ func (r *serviceConfigSQLRepository) upsert(
On("conflict(cloud_integration_id, type) do update set config=excluded.config, updated_at=excluded.updated_at").
Exec(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud service config: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't upsert cloud service config")
}
return &serviceConfig.Config, nil
return config, nil
}
func (r *serviceConfigSQLRepository) getAllForAccount(
func (r *serviceConfigSQLRepository) GetAllForAccount(
ctx context.Context,
orgID string,
cloudAccountId string,
) (map[string]*types.CloudServiceConfig, *model.ApiError) {
serviceConfigs := []types.CloudIntegrationService{}
) (map[string][]byte, error) {
var serviceConfigs []integrationstypes.CloudIntegrationService
err := r.store.BunDB().NewSelect().
Model(&serviceConfigs).
@@ -149,15 +143,13 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
Where("ci.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not query service configs from db: %w", err,
))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query service configs from db")
}
result := map[string]*types.CloudServiceConfig{}
result := make(map[string][]byte)
for _, r := range serviceConfigs {
result[r.Type] = &r.Config
result[r.Type] = r.Config
}
return result, nil

View File

@@ -6,11 +6,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/queryparser"
"log/slog"
"io"
"math"
@@ -25,14 +21,18 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/errors"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/prometheus/promql"
@@ -44,7 +44,6 @@ import (
"github.com/SigNoz/signoz/pkg/contextlinks"
traceFunnelsModule "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/inframetrics"
queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues"
"github.com/SigNoz/signoz/pkg/query-service/app/logs"
@@ -113,7 +112,7 @@ type APIHandler struct {
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
cloudIntegrationsRegistry map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
@@ -163,7 +162,7 @@ type APIHandlerOpts struct {
IntegrationsController *integrations.Controller
// Cloud Provider Integrations
CloudIntegrationsController *cloudintegrations.Controller
CloudIntegrationsRegistry map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider
// Log parsing pipelines
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
@@ -220,7 +219,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
temporalityMap: make(map[string]map[v3.Temporality]bool),
ruleManager: opts.RuleManager,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
cloudIntegrationsRegistry: opts.CloudIntegrationsRegistry,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
querier: querier,
querierV2: querierv2,
@@ -1217,13 +1216,22 @@ func (aH *APIHandler) Get(rw http.ResponseWriter, r *http.Request) {
}
dashboard := new(dashboardtypes.Dashboard)
if aH.CloudIntegrationsController.IsCloudIntegrationDashboardUuid(id) {
cloudIntegrationDashboard, apiErr := aH.CloudIntegrationsController.GetDashboardById(ctx, orgID, id)
if apiErr != nil {
render.Error(rw, errorsV2.Wrapf(apiErr, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to get dashboard"))
if integrationstypes.IsCloudIntegrationDashboardUuid(id) {
cloudProvider, err := integrationstypes.GetCloudProviderFromDashboardID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard = cloudIntegrationDashboard
integrationDashboard, err := aH.cloudIntegrationsRegistry[cloudProvider].GetDashboard(ctx, &integrationstypes.GettableDashboard{
ID: id,
OrgID: orgID,
})
if err != nil {
render.Error(rw, err)
return
}
dashboard = integrationDashboard
} else if aH.IntegrationsController.IsInstalledIntegrationDashboardID(id) {
integrationDashboard, apiErr := aH.IntegrationsController.GetInstalledIntegrationDashboardById(ctx, orgID, id)
if apiErr != nil {
@@ -1287,11 +1295,13 @@ func (aH *APIHandler) List(rw http.ResponseWriter, r *http.Request) {
dashboards = append(dashboards, installedIntegrationDashboards...)
}
cloudIntegrationDashboards, apiErr := aH.CloudIntegrationsController.AvailableDashboards(ctx, orgID)
if apiErr != nil {
zap.L().Error("failed to get dashboards for cloud integrations", zap.Error(apiErr))
} else {
dashboards = append(dashboards, cloudIntegrationDashboards...)
for _, provider := range aH.cloudIntegrationsRegistry {
cloudIntegrationDashboards, err := provider.GetAvailableDashboards(ctx, orgID)
if err != nil {
zap.L().Error("failed to get dashboards for cloud integrations", zap.Error(apiErr))
} else {
dashboards = append(dashboards, cloudIntegrationDashboards...)
}
}
gettableDashboards, err := dashboardtypes.NewGettableDashboardsFromDashboards(dashboards)
@@ -3267,15 +3277,15 @@ func (aH *APIHandler) GetIntegrationConnectionStatus(w http.ResponseWriter, r *h
lookbackSeconds = 15 * 60
}
connectionStatus, apiErr := aH.calculateConnectionStatus(
connectionStatus, err := aH.calculateConnectionStatus(
r.Context(), orgID, connectionTests, lookbackSeconds,
)
if apiErr != nil {
RespondError(w, apiErr, "Failed to calculate integration connection status")
if err != nil {
render.Error(w, err)
return
}
aH.Respond(w, connectionStatus)
render.Success(w, http.StatusOK, connectionStatus)
}
func (aH *APIHandler) calculateConnectionStatus(
@@ -3283,10 +3293,11 @@ func (aH *APIHandler) calculateConnectionStatus(
orgID valuer.UUID,
connectionTests *integrations.IntegrationConnectionTests,
lookbackSeconds int64,
) (*integrations.IntegrationConnectionStatus, *model.ApiError) {
) (*integrations.IntegrationConnectionStatus, error) {
// Calculate connection status for signals in parallel
result := &integrations.IntegrationConnectionStatus{}
// TODO: migrate to errors package
errors := []*model.ApiError{}
var resultLock sync.Mutex
@@ -3484,12 +3495,14 @@ func (aH *APIHandler) UninstallIntegration(w http.ResponseWriter, r *http.Reques
aH.Respond(w, map[string]interface{}{})
}
// cloud provider integrations
// RegisterCloudIntegrationsRoutes register routes for cloud provider integrations
func (aH *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
subRouter := router.PathPrefix("/api/v1/cloud-integrations").Subrouter()
subRouter.Use(middleware.NewRecovery(aH.Signoz.Instrumentation.Logger()).Wrap)
subRouter.HandleFunc(
"/{cloudProvider}/accounts/generate-connection-url", am.EditAccess(aH.CloudIntegrationsGenerateConnectionUrl),
"/{cloudProvider}/accounts/generate-connection-url", am.EditAccess(aH.CloudIntegrationsGenerateConnectionArtifact),
).Methods(http.MethodPost)
subRouter.HandleFunc(
@@ -3523,170 +3536,199 @@ func (aH *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *mi
subRouter.HandleFunc(
"/{cloudProvider}/services/{serviceId}/config", am.EditAccess(aH.CloudIntegrationsUpdateServiceConfig),
).Methods(http.MethodPost)
}
func (aH *APIHandler) CloudIntegrationsListConnectedAccounts(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
resp, apiErr := aH.CloudIntegrationsController.ListConnectedAccounts(
r.Context(), claims.OrgID, cloudProvider,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
}
aH.Respond(w, resp)
}
func (aH *APIHandler) CloudIntegrationsGenerateConnectionUrl(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
req := cloudintegrations.GenerateConnectionUrlRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
result, apiErr := aH.CloudIntegrationsController.GenerateConnectionUrl(
r.Context(), claims.OrgID, cloudProvider, req,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
}
aH.Respond(w, result)
}
func (aH *APIHandler) CloudIntegrationsGetAccountStatus(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
accountId := mux.Vars(r)["accountId"]
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
resp, apiErr := aH.CloudIntegrationsController.GetAccountStatus(
r.Context(), claims.OrgID, cloudProvider, accountId,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
}
aH.Respond(w, resp)
}
func (aH *APIHandler) CloudIntegrationsAgentCheckIn(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
req := cloudintegrations.AgentCheckInRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
result, err := aH.CloudIntegrationsController.CheckInAsAgent(
r.Context(), claims.OrgID, cloudProvider, req,
)
func (aH *APIHandler) CloudIntegrationsGenerateConnectionArtifact(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
aH.Respond(w, result)
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
reqBody, err := io.ReadAll(r.Body)
if err != nil {
render.Error(w, errors.WrapInternalf(err, errors.CodeInternal, "failed to read request body"))
return
}
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].GenerateConnectionArtifact(r.Context(), &integrationstypes.PostableConnectionArtifact{
OrgID: claims.OrgID,
Data: reqBody,
})
if err != nil {
aH.Signoz.Instrumentation.Logger().ErrorContext(r.Context(),
"failed to generate connection artifact for cloud integration",
slog.String("cloudProvider", cloudProviderString),
slog.String("orgID", claims.OrgID),
)
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, resp)
}
func (aH *APIHandler) CloudIntegrationsUpdateAccountConfig(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
func (aH *APIHandler) CloudIntegrationsListConnectedAccounts(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].ListConnectedAccounts(r.Context(), claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, resp)
}
func (aH *APIHandler) CloudIntegrationsGetAccountStatus(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
accountId := mux.Vars(r)["accountId"]
req := cloudintegrations.UpdateAccountConfigRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].GetAccountStatus(r.Context(), claims.OrgID, accountId)
if err != nil {
render.Error(w, err)
return
}
result, apiErr := aH.CloudIntegrationsController.UpdateAccountConfig(
r.Context(), claims.OrgID, cloudProvider, accountId, req,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
}
aH.Respond(w, result)
render.Success(w, http.StatusOK, resp)
}
func (aH *APIHandler) CloudIntegrationsDisconnectAccount(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
func (aH *APIHandler) CloudIntegrationsAgentCheckIn(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
req := new(integrationstypes.PostableAgentCheckInPayload)
if err = json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid request body"))
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
req.OrgID = claims.OrgID
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].AgentCheckIn(r.Context(), req)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, resp)
}
func (aH *APIHandler) CloudIntegrationsUpdateAccountConfig(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
accountId := mux.Vars(r)["accountId"]
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
reqBody, err := io.ReadAll(r.Body)
if err != nil {
render.Error(w, errors.WrapInternalf(err, errors.CodeInternal, "failed to read request body"))
return
}
result, apiErr := aH.CloudIntegrationsController.DisconnectAccount(
r.Context(), claims.OrgID, cloudProvider, accountId,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].UpdateAccountConfig(r.Context(), &integrationstypes.PatchableAccountConfig{
OrgID: claims.OrgID,
AccountId: accountId,
Data: reqBody,
})
if err != nil {
render.Error(w, err)
return
}
aH.Respond(w, result)
render.Success(w, http.StatusOK, resp)
return
}
func (aH *APIHandler) CloudIntegrationsListServices(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
func (aH *APIHandler) CloudIntegrationsDisconnectAccount(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
accountId := mux.Vars(r)["accountId"]
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
result, err := aH.cloudIntegrationsRegistry[cloudProvider].DisconnectAccount(r.Context(), claims.OrgID, accountId)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, result)
}
func (aH *APIHandler) CloudIntegrationsListServices(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
var cloudAccountId *string
@@ -3695,26 +3737,22 @@ func (aH *APIHandler) CloudIntegrationsListServices(
cloudAccountId = &cloudAccountIdQP
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
resp, apiErr := aH.CloudIntegrationsController.ListServices(
r.Context(), claims.OrgID, cloudProvider, cloudAccountId,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].ListServices(r.Context(), claims.OrgID, cloudAccountId)
if err != nil {
render.Error(w, err)
return
}
aH.Respond(w, resp)
render.Success(w, http.StatusOK, resp)
}
func (aH *APIHandler) CloudIntegrationsGetServiceDetails(
w http.ResponseWriter, r *http.Request,
) {
func (aH *APIHandler) CloudIntegrationsGetServiceDetails(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
@@ -3726,7 +3764,14 @@ func (aH *APIHandler) CloudIntegrationsGetServiceDetails(
return
}
cloudProvider := mux.Vars(r)["cloudProvider"]
cloudProviderString := mux.Vars(r)["cloudProvider"]
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
render.Error(w, err)
return
}
serviceId := mux.Vars(r)["serviceId"]
var cloudAccountId *string
@@ -3736,270 +3781,59 @@ func (aH *APIHandler) CloudIntegrationsGetServiceDetails(
cloudAccountId = &cloudAccountIdQP
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
resp, err := aH.CloudIntegrationsController.GetServiceDetails(
r.Context(), claims.OrgID, cloudProvider, serviceId, cloudAccountId,
)
resp, err := aH.cloudIntegrationsRegistry[cloudProvider].GetServiceDetails(r.Context(), &integrationstypes.GetServiceDetailsReq{
OrgID: orgID.String(),
ServiceId: serviceId,
CloudAccountID: cloudAccountId,
})
if err != nil {
render.Error(w, err)
return
}
// Add connection status for the 2 signals.
if cloudAccountId != nil {
connStatus, apiErr := aH.calculateCloudIntegrationServiceConnectionStatus(
r.Context(), orgID, cloudProvider, *cloudAccountId, resp,
)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
}
resp.ConnectionStatus = connStatus
}
aH.Respond(w, resp)
render.Success(w, http.StatusOK, resp)
return
}
func (aH *APIHandler) calculateCloudIntegrationServiceConnectionStatus(
ctx context.Context,
orgID valuer.UUID,
cloudProvider string,
cloudAccountId string,
svcDetails *cloudintegrations.ServiceDetails,
) (*cloudintegrations.ServiceConnectionStatus, *model.ApiError) {
if cloudProvider != "aws" {
// TODO(Raj): Make connection check generic for all providers in a follow up change
return nil, model.BadRequest(
fmt.Errorf("unsupported cloud provider: %s", cloudProvider),
)
}
func (aH *APIHandler) CloudIntegrationsUpdateServiceConfig(w http.ResponseWriter, r *http.Request) {
cloudProviderString := mux.Vars(r)["cloudProvider"]
telemetryCollectionStrategy := svcDetails.Strategy
if telemetryCollectionStrategy == nil {
return nil, model.InternalError(fmt.Errorf(
"service doesn't have telemetry collection strategy: %s", svcDetails.Id,
))
}
result := &cloudintegrations.ServiceConnectionStatus{}
errors := []*model.ApiError{}
var resultLock sync.Mutex
var wg sync.WaitGroup
// Calculate metrics connection status
if telemetryCollectionStrategy.AWSMetrics != nil {
wg.Add(1)
go func() {
defer wg.Done()
metricsConnStatus, apiErr := aH.calculateAWSIntegrationSvcMetricsConnectionStatus(
ctx, cloudAccountId, telemetryCollectionStrategy.AWSMetrics, svcDetails.DataCollected.Metrics,
)
resultLock.Lock()
defer resultLock.Unlock()
if apiErr != nil {
errors = append(errors, apiErr)
} else {
result.Metrics = metricsConnStatus
}
}()
}
// Calculate logs connection status
if telemetryCollectionStrategy.AWSLogs != nil {
wg.Add(1)
go func() {
defer wg.Done()
logsConnStatus, apiErr := aH.calculateAWSIntegrationSvcLogsConnectionStatus(
ctx, orgID, cloudAccountId, telemetryCollectionStrategy.AWSLogs,
)
resultLock.Lock()
defer resultLock.Unlock()
if apiErr != nil {
errors = append(errors, apiErr)
} else {
result.Logs = logsConnStatus
}
}()
}
wg.Wait()
if len(errors) > 0 {
return nil, errors[0]
}
return result, nil
}
func (aH *APIHandler) calculateAWSIntegrationSvcMetricsConnectionStatus(
ctx context.Context,
cloudAccountId string,
strategy *services.AWSMetricsStrategy,
metricsCollectedBySvc []services.CollectedMetric,
) (*cloudintegrations.SignalConnectionStatus, *model.ApiError) {
if strategy == nil || len(strategy.StreamFilters) < 1 {
return nil, nil
}
expectedLabelValues := map[string]string{
"cloud_provider": "aws",
"cloud_account_id": cloudAccountId,
}
metricsNamespace := strategy.StreamFilters[0].Namespace
metricsNamespaceParts := strings.Split(metricsNamespace, "/")
if len(metricsNamespaceParts) >= 2 {
expectedLabelValues["service_namespace"] = metricsNamespaceParts[0]
expectedLabelValues["service_name"] = metricsNamespaceParts[1]
} else {
// metrics for single word namespaces like "CWAgent" do not
// have the service_namespace label populated
expectedLabelValues["service_name"] = metricsNamespaceParts[0]
}
metricNamesCollectedBySvc := []string{}
for _, cm := range metricsCollectedBySvc {
metricNamesCollectedBySvc = append(metricNamesCollectedBySvc, cm.Name)
}
statusForLastReceivedMetric, apiErr := aH.reader.GetLatestReceivedMetric(
ctx, metricNamesCollectedBySvc, expectedLabelValues,
)
if apiErr != nil {
return nil, apiErr
}
if statusForLastReceivedMetric != nil {
return &cloudintegrations.SignalConnectionStatus{
LastReceivedTsMillis: statusForLastReceivedMetric.LastReceivedTsMillis,
LastReceivedFrom: "signoz-aws-integration",
}, nil
}
return nil, nil
}
func (aH *APIHandler) calculateAWSIntegrationSvcLogsConnectionStatus(
ctx context.Context,
orgID valuer.UUID,
cloudAccountId string,
strategy *services.AWSLogsStrategy,
) (*cloudintegrations.SignalConnectionStatus, *model.ApiError) {
if strategy == nil || len(strategy.Subscriptions) < 1 {
return nil, nil
}
logGroupNamePrefix := strategy.Subscriptions[0].LogGroupNamePrefix
if len(logGroupNamePrefix) < 1 {
return nil, nil
}
logsConnTestFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "cloud.account.id",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: "=",
Value: cloudAccountId,
},
{
Key: v3.AttributeKey{
Key: "aws.cloudwatch.log_group_name",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: "like",
Value: logGroupNamePrefix + "%",
},
},
}
// TODO(Raj): Receive this as a param from UI in the future.
lookbackSeconds := int64(30 * 60)
qrParams := &v3.QueryRangeParamsV3{
Start: time.Now().UnixMilli() - (lookbackSeconds * 1000),
End: time.Now().UnixMilli(),
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeList,
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
PageSize: 1,
Filters: logsConnTestFilter,
QueryName: "A",
DataSource: v3.DataSourceLogs,
Expression: "A",
AggregateOperator: v3.AggregateOperatorNoOp,
},
},
},
}
queryRes, _, err := aH.querier.QueryRange(
ctx, orgID, qrParams,
)
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
if err != nil {
return nil, model.InternalError(fmt.Errorf(
"could not query for integration connection status: %w", err,
))
}
if len(queryRes) > 0 && queryRes[0].List != nil && len(queryRes[0].List) > 0 {
lastLog := queryRes[0].List[0]
return &cloudintegrations.SignalConnectionStatus{
LastReceivedTsMillis: lastLog.Timestamp.UnixMilli(),
LastReceivedFrom: "signoz-aws-integration",
}, nil
render.Error(w, err)
return
}
return nil, nil
}
func (aH *APIHandler) CloudIntegrationsUpdateServiceConfig(
w http.ResponseWriter, r *http.Request,
) {
cloudProvider := mux.Vars(r)["cloudProvider"]
serviceId := mux.Vars(r)["serviceId"]
req := cloudintegrations.UpdateServiceConfigRequest{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
render.Error(w, errv2)
return
}
result, err := aH.CloudIntegrationsController.UpdateServiceConfig(
r.Context(), claims.OrgID, cloudProvider, serviceId, &req,
)
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
aH.Respond(w, result)
reqBody, err := io.ReadAll(r.Body)
if err != nil {
render.Error(w, errors.WrapInternalf(err,
errors.CodeInternal,
"failed to read update service config request body",
))
return
}
result, err := aH.cloudIntegrationsRegistry[cloudProvider].UpdateServiceConfig(
r.Context(), &integrationstypes.PatchableServiceConfig{
OrgID: claims.OrgID,
ServiceId: serviceId,
Config: reqBody,
},
)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, result)
}
// logs

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -107,7 +108,7 @@ type IntegrationsListItem struct {
type Integration struct {
IntegrationDetails
Installation *types.InstalledIntegration `json:"installation"`
Installation *integrationstypes.InstalledIntegration `json:"installation"`
}
type Manager struct {
@@ -223,7 +224,7 @@ func (m *Manager) InstallIntegration(
ctx context.Context,
orgId string,
integrationId string,
config types.InstalledIntegrationConfig,
config integrationstypes.InstalledIntegrationConfig,
) (*IntegrationsListItem, *model.ApiError) {
integrationDetails, apiErr := m.getIntegrationDetails(ctx, integrationId)
if apiErr != nil {
@@ -429,7 +430,7 @@ func (m *Manager) getInstalledIntegration(
ctx context.Context,
orgId string,
integrationId string,
) (*types.InstalledIntegration, *model.ApiError) {
) (*integrationstypes.InstalledIntegration, *model.ApiError) {
iis, apiErr := m.installedIntegrationsRepo.get(
ctx, orgId, []string{integrationId},
)
@@ -457,7 +458,7 @@ func (m *Manager) getInstalledIntegrations(
return nil, apiErr
}
installedTypes := utils.MapSlice(installations, func(i types.InstalledIntegration) string {
installedTypes := utils.MapSlice(installations, func(i integrationstypes.InstalledIntegration) string {
return i.Type
})
integrationDetails, apiErr := m.availableIntegrationsRepo.get(ctx, installedTypes)

View File

@@ -4,22 +4,22 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
)
type InstalledIntegrationsRepo interface {
list(ctx context.Context, orgId string) ([]types.InstalledIntegration, *model.ApiError)
list(ctx context.Context, orgId string) ([]integrationstypes.InstalledIntegration, *model.ApiError)
get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError)
) (map[string]integrationstypes.InstalledIntegration, *model.ApiError)
upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError)
config integrationstypes.InstalledIntegrationConfig,
) (*integrationstypes.InstalledIntegration, *model.ApiError)
delete(ctx context.Context, orgId string, integrationType string) *model.ApiError
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -26,8 +27,8 @@ func NewInstalledIntegrationsSqliteRepo(store sqlstore.SQLStore) (
func (r *InstalledIntegrationsSqliteRepo) list(
ctx context.Context,
orgId string,
) ([]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) ([]integrationstypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationstypes.InstalledIntegration{}
err := r.store.BunDB().NewSelect().
Model(&integrations).
@@ -44,8 +45,8 @@ func (r *InstalledIntegrationsSqliteRepo) list(
func (r *InstalledIntegrationsSqliteRepo) get(
ctx context.Context, orgId string, integrationTypes []string,
) (map[string]types.InstalledIntegration, *model.ApiError) {
integrations := []types.InstalledIntegration{}
) (map[string]integrationstypes.InstalledIntegration, *model.ApiError) {
integrations := []integrationstypes.InstalledIntegration{}
typeValues := []interface{}{}
for _, integrationType := range integrationTypes {
@@ -62,7 +63,7 @@ func (r *InstalledIntegrationsSqliteRepo) get(
))
}
result := map[string]types.InstalledIntegration{}
result := map[string]integrationstypes.InstalledIntegration{}
for _, ii := range integrations {
result[ii.Type] = ii
}
@@ -74,10 +75,10 @@ func (r *InstalledIntegrationsSqliteRepo) upsert(
ctx context.Context,
orgId string,
integrationType string,
config types.InstalledIntegrationConfig,
) (*types.InstalledIntegration, *model.ApiError) {
config integrationstypes.InstalledIntegrationConfig,
) (*integrationstypes.InstalledIntegration, *model.ApiError) {
integration := types.InstalledIntegration{
integration := integrationstypes.InstalledIntegration{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -114,7 +115,7 @@ func (r *InstalledIntegrationsSqliteRepo) delete(
ctx context.Context, orgId string, integrationType string,
) *model.ApiError {
_, dbErr := r.store.BunDB().NewDelete().
Model(&types.InstalledIntegration{}).
Model(&integrationstypes.InstalledIntegration{}).
Where("type = ?", integrationType).
Where("org_id = ?", orgId).
Exec(ctx)

View File

@@ -71,10 +71,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
if err != nil {
return nil, err
}
cloudIntegrationsRegistry := cloudintegrations.NewCloudProviderRegistry(signoz.Instrumentation.Logger(), signoz.SQLStore, signoz.Querier)
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
Provider: "memory",
@@ -127,7 +124,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
Reader: reader,
RuleManager: rm,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
CloudIntegrationsRegistry: cloudIntegrationsRegistry,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: config.Querier.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),

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