mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-25 21:30:26 +01:00
Compare commits
48 Commits
fix/root-u
...
feat/azure
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00dcd8526 | ||
|
|
3d3aa9d977 | ||
|
|
9068cddbe0 | ||
|
|
7f1d350ffe | ||
|
|
1d3134959d | ||
|
|
b86bd24dd9 | ||
|
|
4c49d45cbf | ||
|
|
9b3d3453b1 | ||
|
|
9d981d8a13 | ||
|
|
6de4520a95 | ||
|
|
f566909320 | ||
|
|
aa39db8ac2 | ||
|
|
eb2c6b78c8 | ||
|
|
2d2d0c3d9f | ||
|
|
73453ec8c6 | ||
|
|
8a4544cbac | ||
|
|
b333e44d13 | ||
|
|
5bd51df854 | ||
|
|
989ca522f8 | ||
|
|
9a2e9d76b5 | ||
|
|
2be42deecd | ||
|
|
95cad880cc | ||
|
|
cfef1091b3 | ||
|
|
4504c364f2 | ||
|
|
1a006870e1 | ||
|
|
e7a27a1cfb | ||
|
|
1e7323ead2 | ||
|
|
af4c6c5b52 | ||
|
|
02262ba245 | ||
|
|
df7c9e1339 | ||
|
|
ac5e52479f | ||
|
|
de56477bbb | ||
|
|
fddd8a27fa | ||
|
|
2aa4f8e237 | ||
|
|
74006a214b | ||
|
|
ed2cbacadc | ||
|
|
3cbd529843 | ||
|
|
78b481e895 | ||
|
|
215098ec0d | ||
|
|
5a4ef2e4ce | ||
|
|
b1f33c4f7f | ||
|
|
713c84b1e4 | ||
|
|
c3daf9e428 | ||
|
|
70a908deb1 | ||
|
|
cc9cdded3c | ||
|
|
77067cd614 | ||
|
|
ab703d9a65 | ||
|
|
611e8fbf9e |
@@ -176,25 +176,6 @@ Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen S
|
||||
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
|
||||
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
|
||||
|
||||
### Unsere Projektbetreuer
|
||||
|
||||
#### Backend
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
|
||||
#### Frontend
|
||||
|
||||
- [Palash Gupta](https://github.com/palashgdev)
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Rajat Dabade](https://github.com/Rajat-Dabade)
|
||||
|
||||
#### DevOps
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
|
||||
<br /><br />
|
||||
|
||||
## Dokumentation
|
||||
|
||||
28
README.md
28
README.md
@@ -221,34 +221,6 @@ We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING
|
||||
|
||||
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
|
||||
|
||||
### Project maintainers
|
||||
|
||||
#### Backend
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
- [Shivanshu Raj Shrivastava](https://github.com/shivanshuraj1333)
|
||||
- [Ekansh Gupta](https://github.com/eKuG)
|
||||
- [Aniket Agarwal](https://github.com/aniketio-ctrl)
|
||||
|
||||
#### Frontend
|
||||
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Vikrant Gupta](https://github.com/vikrantgupta25)
|
||||
- [Sagar Rajput](https://github.com/SagarRajput-7)
|
||||
- [Shaheer Kochai](https://github.com/ahmadshaheer)
|
||||
- [Amlan Kumar Nandy](https://github.com/amlannandy)
|
||||
- [Sahil Khan](https://github.com/sawhil)
|
||||
- [Aditya Singh](https://github.com/aks07)
|
||||
- [Abhi Kumar](https://github.com/ahrefabhi)
|
||||
|
||||
#### DevOps
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
- [Vibhu Pandey](https://github.com/therealpandey)
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
|
||||
@@ -187,25 +187,6 @@ Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metric
|
||||
|
||||
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
|
||||
|
||||
### 项目维护人员
|
||||
|
||||
#### 后端
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
|
||||
#### 前端
|
||||
|
||||
- [Palash Gupta](https://github.com/palashgdev)
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Rajat Dabade](https://github.com/Rajat-Dabade)
|
||||
|
||||
#### 运维开发
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
|
||||
<br /><br />
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -294,7 +294,6 @@ flagger:
|
||||
config:
|
||||
boolean:
|
||||
use_span_metrics: true
|
||||
interpolation_enabled: false
|
||||
kafka_span_eval: false
|
||||
string:
|
||||
float:
|
||||
@@ -309,3 +308,14 @@ user:
|
||||
allow_self: true
|
||||
# The duration within which a user can reset their password.
|
||||
max_token_lifetime: 6h
|
||||
root:
|
||||
# Whether to enable the root user. When enabled, a root user is provisioned
|
||||
# on startup using the email and password below. The root user cannot be
|
||||
# deleted, updated, or have their password changed through the UI.
|
||||
enabled: false
|
||||
# The email address of the root user.
|
||||
email: ""
|
||||
# The password of the root user. Must meet password requirements.
|
||||
password: ""
|
||||
# The name of the organization to create or look up for the root user.
|
||||
org_name: default
|
||||
|
||||
@@ -4678,6 +4678,8 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
isRoot:
|
||||
type: boolean
|
||||
orgId:
|
||||
type: string
|
||||
role:
|
||||
|
||||
@@ -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 {
|
||||
@@ -45,12 +47,12 @@ type APIHandler struct {
|
||||
}
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
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),
|
||||
@@ -58,7 +60,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
})
|
||||
}, config)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 +162,16 @@ 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)
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
"@signozhq/radio-group": "0.0.2",
|
||||
"@signozhq/resizable": "0.0.0",
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/table": "0.3.7",
|
||||
|
||||
@@ -1542,6 +1542,10 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isRoot?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -21,6 +21,7 @@ import '@signozhq/design-tokens';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/radio-group';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/sonner';
|
||||
import '@signozhq/table';
|
||||
|
||||
@@ -9,74 +9,6 @@
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -535,15 +467,6 @@
|
||||
.dashboard-description-container {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
.dashboard-breadcrumbs {
|
||||
.dashboard-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
.left-section {
|
||||
.dashboard-title {
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
@@ -27,7 +25,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import {
|
||||
Check,
|
||||
@@ -37,7 +34,6 @@ import {
|
||||
FolderKanban,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
X,
|
||||
@@ -51,6 +47,7 @@ import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardGraphSlider from '../ComponentsSlider';
|
||||
import DashboardSettings from '../DashboardSettings';
|
||||
import { Base64Icons } from '../DashboardSettings/General/utils';
|
||||
@@ -71,7 +68,6 @@ interface DashboardDescriptionProps {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { handle } = props;
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -80,7 +76,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
layouts,
|
||||
setLayouts,
|
||||
isDashboardLocked,
|
||||
listSortOrder,
|
||||
setSelectedDashboard,
|
||||
handleToggleDashboardSlider,
|
||||
setSelectedRowWidgetId,
|
||||
@@ -292,17 +287,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
function goToListPage(): void {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
@@ -351,32 +335,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<div className="dashboard-header">
|
||||
<section className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={(): void => goToListPage()}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
<DashboardHeader />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboard-icon-image {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dashboard-breadcrumbs {
|
||||
.dashboard-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { Base64Icons } from '../../DashboardSettings/General/utils';
|
||||
|
||||
import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
function DashboardBreadcrumbs(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedDashboard, listSortOrder } = useDashboard();
|
||||
|
||||
const selectedData = selectedDashboard
|
||||
? {
|
||||
...selectedDashboard.data,
|
||||
uuid: selectedDashboard.id,
|
||||
}
|
||||
: ({} as DashboardData);
|
||||
|
||||
const { title = '', image = Base64Icons[0] } = selectedData || {};
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}, [listSortOrder, safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img src={image} alt="dashboard-icon" className="dashboard-icon-image" />
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -0,0 +1,15 @@
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import './DashboardHeader.styles.scss';
|
||||
|
||||
function DashboardHeader(): JSX.Element {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardBreadcrumbs />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -23,6 +23,7 @@ export default function ChartWrapper({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
syncMode,
|
||||
syncKey,
|
||||
@@ -36,6 +37,9 @@ export default function ChartWrapper({
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
@@ -44,7 +48,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position],
|
||||
[config, legendConfig.position, showLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -60,6 +64,7 @@ export default function ChartWrapper({
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
showLegend={showLegend}
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
HistogramTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { HistogramChartProps } from '../types';
|
||||
|
||||
export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
renderTooltip: customRenderTooltip,
|
||||
isQueriesMerged,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
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: HistogramTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
content,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
showLegend={!isQueriesMerged}
|
||||
{...rest}
|
||||
renderTooltip={renderTooltip}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
@@ -17,6 +18,7 @@ interface BaseChartProps {
|
||||
interface UPlotBasedChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
data: uPlot.AlignedData;
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
@@ -26,14 +28,20 @@ interface UPlotBasedChartProps {
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type ChartProps = TimeSeriesChartProps | BarChartProps;
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
focus: jest.Mock;
|
||||
};
|
||||
|
||||
function createMockElement(): MockHTMLElement {
|
||||
return {
|
||||
scrollIntoView: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
expect(mockElement.scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(mockElement.focus).toHaveBeenCalled();
|
||||
expect(setToScrollWidgetId).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
expect(mockElement.scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(mockElement.focus).not.toHaveBeenCalled();
|
||||
expect(setToScrollWidgetId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
* requests it via `toScrollWidgetId`.
|
||||
*
|
||||
* Intended for use in panel components that render a single widget.
|
||||
*/
|
||||
export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
widgetContainerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
widgetContainerRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widgetId, widgetContainerRef]);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import './ChartLayout.styles.scss';
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
showLegend?: boolean;
|
||||
legendComponent: (legendPerSet: number) => React.ReactNode;
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
@@ -20,6 +22,7 @@ export interface ChartLayoutProps {
|
||||
config: UPlotConfigBuilder;
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
@@ -30,6 +33,15 @@ export default function ChartLayout({
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
if (!showLegend) {
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
@@ -42,7 +54,7 @@ export default function ChartLayout({
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -60,15 +72,17 @@ export default function ChartLayout({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{layoutChildren}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
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 ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
@@ -27,7 +27,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
@@ -36,16 +35,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
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 { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import {
|
||||
prepareHistogramPanelConfig,
|
||||
prepareHistogramPanelData,
|
||||
} from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
panelMode,
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
panelMode,
|
||||
});
|
||||
}, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!queryResponse?.data?.payload) {
|
||||
return [];
|
||||
}
|
||||
return prepareHistogramPanelData({
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
bucketWidth: widget?.bucketWidth,
|
||||
bucketCount: widget?.bucketCount,
|
||||
mergeAllActiveQueries: widget?.mergeAllActiveQueries,
|
||||
});
|
||||
}, [
|
||||
queryResponse?.data?.payload,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
if (!isFullViewMode || widget.mergeAllActiveQueries) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onCancel={onToggleModelHandler}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isFullViewMode,
|
||||
config,
|
||||
chartData,
|
||||
widget.yAxisUnit,
|
||||
onToggleModelHandler,
|
||||
widget.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
plotRef={(plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}}
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanel;
|
||||
@@ -0,0 +1,223 @@
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
MetricQueryRangeSuccessResponse,
|
||||
MetricRangePayloadProps,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import HistogramPanel from '../HistogramPanel';
|
||||
import { HistogramChartProps } from 'container/DashboardContainer/visualization/charts/types';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 800, height: 400 }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
// Provide a no-op provider component so AllTheProviders can render
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<>{children}</>
|
||||
),
|
||||
// And mock the hook used by HistogramPanel
|
||||
useTimezone: jest.fn().mockReturnValue({
|
||||
timezone: { value: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView',
|
||||
() => ({
|
||||
useScrollWidgetIntoView: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/Histogram/Histogram',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (props: HistogramChartProps): JSX.Element => (
|
||||
<div data-testid="histogram-chart">
|
||||
<div data-testid="histogram-props">
|
||||
{JSON.stringify({
|
||||
legendPosition: props.legendConfig?.position,
|
||||
isQueriesMerged: props.isQueriesMerged,
|
||||
yAxisUnit: props.yAxisUnit,
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
})}
|
||||
</div>
|
||||
{props.layoutChildren}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/components/ChartManager/ChartManager',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="chart-manager">ChartManager</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function createQueryResponse(
|
||||
payloadOverrides: Partial<MetricRangePayloadProps> = {},
|
||||
): { data: { payload: MetricRangePayloadProps } } {
|
||||
const basePayload: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
legend: 'Series A',
|
||||
values: [
|
||||
[1, '10'],
|
||||
[2, '20'],
|
||||
],
|
||||
},
|
||||
],
|
||||
resultType: 'matrix',
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
resultType: 'matrix',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
data: {
|
||||
payload: {
|
||||
...basePayload,
|
||||
...payloadOverrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type WidgetLike = {
|
||||
id: string;
|
||||
yAxisUnit: string;
|
||||
decimalPrecision: number;
|
||||
legendPosition: LegendPosition;
|
||||
mergeAllActiveQueries: boolean;
|
||||
};
|
||||
|
||||
function createWidget(overrides: Partial<WidgetLike> = {}): WidgetLike {
|
||||
return {
|
||||
id: 'widget-id',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
legendPosition: LegendPosition.BOTTOM,
|
||||
mergeAllActiveQueries: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HistogramPanel', () => {
|
||||
it('renders Histogram when container has dimensions', () => {
|
||||
const widget = (createWidget() as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode={false}
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('histogram-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes legend position and other props to Histogram', () => {
|
||||
const widget = (createWidget({
|
||||
legendPosition: LegendPosition.RIGHT,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode={false}
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const propsJson = screen.getByTestId('histogram-props').textContent || '{}';
|
||||
const parsed = JSON.parse(propsJson);
|
||||
|
||||
expect(parsed.legendPosition).toBe(LegendPosition.RIGHT);
|
||||
expect(parsed.yAxisUnit).toBe('ms');
|
||||
expect(parsed.decimalPrecision).toBe(2);
|
||||
});
|
||||
|
||||
it('renders ChartManager in full view when queries are not merged', () => {
|
||||
const widget = (createWidget({
|
||||
mergeAllActiveQueries: false,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-manager')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render ChartManager when queries are merged', () => {
|
||||
const widget = (createWidget({
|
||||
mergeAllActiveQueries: true,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('chart-manager')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { incrRoundDn, roundDecimals } from 'utils/round';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
import {
|
||||
buildHistogramBuckets,
|
||||
mergeAlignedDataTables,
|
||||
prependNullBinToFirstHistogramSeries,
|
||||
replaceUndefinedWithNullInAlignedData,
|
||||
} from '../utils/histogram';
|
||||
|
||||
export interface PrepareHistogramPanelDataParams {
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const HIST_SORT = (a: number, b: number): number => a - b;
|
||||
|
||||
function extractNumericValues(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
for (const item of result) {
|
||||
for (const [, valueStr] of item.values) {
|
||||
values.push(Number.parseFloat(valueStr) || 0);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildFrames(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = result.map((item) =>
|
||||
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelData({
|
||||
apiResponse,
|
||||
bucketWidth,
|
||||
bucketCount: bucketCountProp = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramPanelDataParams): AlignedData {
|
||||
const bucketCount = bucketCountProp ?? DEFAULT_BUCKET_COUNT;
|
||||
const result = apiResponse.data.result;
|
||||
|
||||
const seriesValues = extractNumericValues(result);
|
||||
if (seriesValues.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...seriesValues].sort((a, b) => a - b);
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const range = max - min;
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(result, mergeAllActiveQueries);
|
||||
const histogramsPerSeries: AlignedData[] = frames
|
||||
.filter((frame) => frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, HIST_SORT));
|
||||
|
||||
if (histogramsPerSeries.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const mergedHistogramData = mergeAlignedDataTables(histogramsPerSeries);
|
||||
replaceUndefinedWithNullInAlignedData(mergedHistogramData);
|
||||
prependNullBinToFirstHistogramSeries(mergedHistogramData, bucketSize);
|
||||
return mergedHistogramData;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelConfig({
|
||||
widget,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
isDarkMode,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
panelMode: PanelMode;
|
||||
isDarkMode: boolean;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
});
|
||||
builder.setCursor({
|
||||
drag: {
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: true,
|
||||
},
|
||||
focus: {
|
||||
prox: 1e3,
|
||||
},
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: true,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: true,
|
||||
min: 0,
|
||||
});
|
||||
|
||||
const currentQuery = widget.query;
|
||||
const mergeAllActiveQueries = widget?.mergeAllActiveQueries ?? false;
|
||||
|
||||
// When merged, data has only one y column; add one series to match. Otherwise add one per result.
|
||||
if (mergeAllActiveQueries) {
|
||||
builder.addSeries({
|
||||
label: '',
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
lineColor: '#3f5ecc',
|
||||
fillColor: '#4E74F8',
|
||||
isDarkMode,
|
||||
});
|
||||
} else {
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = currentQuery
|
||||
? getLegend(series, currentQuery, baseLabelName)
|
||||
: baseLabelName;
|
||||
|
||||
builder.addSeries({
|
||||
label: label,
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
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 { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
@@ -26,7 +26,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
@@ -35,16 +34,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -14,11 +14,6 @@ export interface GraphVisibilityState {
|
||||
dataIndex: SeriesVisibilityItem[];
|
||||
}
|
||||
|
||||
export interface SeriesVisibilityState {
|
||||
labels: string[];
|
||||
visibility: boolean[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context in which a panel is rendered. Used to vary behavior (e.g. persistence,
|
||||
* interactions) per context.
|
||||
|
||||
@@ -62,10 +62,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns visibility by index including duplicate labels', () => {
|
||||
@@ -85,10 +85,11 @@ describe('legendVisibilityUtils', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'CPU', 'Memory'],
|
||||
visibility: [true, false, false],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON in localStorage', () => {
|
||||
@@ -127,10 +128,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a new widget entry when other widgets already exist', () => {
|
||||
@@ -149,7 +150,7 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-new');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({ labels: ['CPU'], visibility: [false] });
|
||||
expect(stored).toEqual([{ label: 'CPU', show: false }]);
|
||||
});
|
||||
|
||||
it('updates existing widget visibility when entry already exists', () => {
|
||||
@@ -175,10 +176,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [false, true],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('silently handles malformed existing JSON without throwing', () => {
|
||||
@@ -201,10 +202,10 @@ describe('legendVisibilityUtils', () => {
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['x-axis', 'CPU'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
const expected = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
@@ -231,14 +232,12 @@ describe('legendVisibilityUtils', () => {
|
||||
{ label: 'B', show: true },
|
||||
]);
|
||||
|
||||
expect(getStoredSeriesVisibility('widget-a')).toEqual({
|
||||
labels: ['A'],
|
||||
visibility: [true],
|
||||
});
|
||||
expect(getStoredSeriesVisibility('widget-b')).toEqual({
|
||||
labels: ['B'],
|
||||
visibility: [true],
|
||||
});
|
||||
expect(getStoredSeriesVisibility('widget-a')).toEqual([
|
||||
{ label: 'A', show: true },
|
||||
]);
|
||||
expect(getStoredSeriesVisibility('widget-b')).toEqual([
|
||||
{ label: 'B', show: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls setItem with storage key and stringified visibility states', () => {
|
||||
|
||||
@@ -19,9 +19,9 @@ export interface BaseConfigBuilderProps {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
isDarkMode: boolean;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
timezone: Timezone;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
timezone?: Timezone;
|
||||
panelMode: PanelMode;
|
||||
panelType: PANEL_TYPES;
|
||||
minTimeScale?: number;
|
||||
@@ -40,8 +40,10 @@ export function buildBaseConfig({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
onDragSelect,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
NULL_EXPAND,
|
||||
NULL_REMOVE,
|
||||
NULL_RETAIN,
|
||||
} from 'container/PanelWrapper/constants';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Expands contiguous runs of `null` values to the left and right of their
|
||||
* original positions so that visual gaps in the series are continuous.
|
||||
*
|
||||
* This is used when `NULL_EXPAND` mode is selected while joining series.
|
||||
*/
|
||||
function propagateNullsAcrossNeighbors(
|
||||
seriesValues: Array<number | null>,
|
||||
nullIndices: number[],
|
||||
alignedLength: number,
|
||||
): void {
|
||||
for (
|
||||
let i = 0, currentIndex, lastExpandedNullIndex = -1;
|
||||
i < nullIndices.length;
|
||||
i++
|
||||
) {
|
||||
const nullIndex = nullIndices[i];
|
||||
|
||||
if (nullIndex > lastExpandedNullIndex) {
|
||||
// expand left until we hit a non-null value
|
||||
currentIndex = nullIndex - 1;
|
||||
while (currentIndex >= 0 && seriesValues[currentIndex] == null) {
|
||||
seriesValues[currentIndex--] = null;
|
||||
}
|
||||
|
||||
// expand right until we hit a non-null value
|
||||
currentIndex = nullIndex + 1;
|
||||
while (currentIndex < alignedLength && seriesValues[currentIndex] == null) {
|
||||
seriesValues[(lastExpandedNullIndex = currentIndex++)] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple uPlot `AlignedData` tables into a single aligned table.
|
||||
*
|
||||
* - Merges and sorts all distinct x-values from each table.
|
||||
* - Re-aligns every series onto the merged x-axis.
|
||||
* - Applies per-series null handling (`NULL_REMOVE`, `NULL_RETAIN`, `NULL_EXPAND`).
|
||||
*/
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
export function mergeAlignedDataTables(
|
||||
alignedTables: AlignedData[],
|
||||
nullModes?: number[][],
|
||||
): AlignedData {
|
||||
let mergedXValues: Set<number>;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
mergedXValues = new Set();
|
||||
|
||||
// Collect all unique x-values from every table.
|
||||
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
|
||||
const table = alignedTables[tableIndex];
|
||||
const xValues = table[0];
|
||||
const xLength = xValues.length;
|
||||
|
||||
for (let i = 0; i < xLength; i++) {
|
||||
mergedXValues.add(xValues[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted, merged x-axis used by the final result.
|
||||
const alignedData: (number | null | undefined)[][] = [
|
||||
Array.from(mergedXValues).sort((a, b) => a - b),
|
||||
];
|
||||
|
||||
const alignedLength = alignedData[0].length;
|
||||
|
||||
// Map from x-value to its index in the merged x-axis.
|
||||
const xValueToIndexMap = new Map<number, number>();
|
||||
|
||||
for (let i = 0; i < alignedLength; i++) {
|
||||
xValueToIndexMap.set(alignedData[0][i] as number, i);
|
||||
}
|
||||
|
||||
// Re-align all series from all tables onto the merged x-axis.
|
||||
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
|
||||
const table = alignedTables[tableIndex];
|
||||
const xValues = table[0];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < table.length; seriesIndex++) {
|
||||
const seriesValues = table[seriesIndex];
|
||||
|
||||
const alignedSeriesValues = Array(alignedLength).fill(undefined);
|
||||
|
||||
const nullHandlingMode = nullModes
|
||||
? nullModes[tableIndex][seriesIndex]
|
||||
: NULL_RETAIN;
|
||||
|
||||
const nullIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < seriesValues.length; i++) {
|
||||
const valueAtPoint = seriesValues[i];
|
||||
const alignedIndex = xValueToIndexMap.get(xValues[i]);
|
||||
|
||||
if (alignedIndex == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueAtPoint === null) {
|
||||
if (nullHandlingMode !== NULL_REMOVE) {
|
||||
alignedSeriesValues[alignedIndex] = valueAtPoint;
|
||||
|
||||
if (nullHandlingMode === NULL_EXPAND) {
|
||||
nullIndices.push(alignedIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alignedSeriesValues[alignedIndex] = valueAtPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally expand nulls to visually preserve gaps.
|
||||
propagateNullsAcrossNeighbors(
|
||||
alignedSeriesValues,
|
||||
nullIndices,
|
||||
alignedLength,
|
||||
);
|
||||
|
||||
alignedData.push(alignedSeriesValues);
|
||||
}
|
||||
}
|
||||
|
||||
return alignedData as AlignedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds histogram buckets from raw values.
|
||||
*
|
||||
* - Each value is mapped into a bucket via `getBucketForValue`.
|
||||
* - Counts how many values fall into each bucket.
|
||||
* - Optionally sorts buckets using the provided comparator.
|
||||
*/
|
||||
export function buildHistogramBuckets(
|
||||
values: number[],
|
||||
getBucketForValue: (value: number) => number,
|
||||
sortBuckets?: ((a: number, b: number) => number) | null,
|
||||
): AlignedData {
|
||||
const bucketMap = new Map<number, { value: number; count: number }>();
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
let value = values[i];
|
||||
|
||||
if (value != null) {
|
||||
value = getBucketForValue(value);
|
||||
}
|
||||
|
||||
const bucket = bucketMap.get(value);
|
||||
|
||||
if (bucket) {
|
||||
bucket.count++;
|
||||
} else {
|
||||
bucketMap.set(value, { value, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = [...bucketMap.values()];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
sortBuckets && buckets.sort((a, b) => sortBuckets(a.value, b.value));
|
||||
|
||||
const bucketValues = Array(buckets.length);
|
||||
const bucketCounts = Array(buckets.length);
|
||||
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
bucketValues[i] = buckets[i].value;
|
||||
bucketCounts[i] = buckets[i].count;
|
||||
}
|
||||
|
||||
return [bucketValues, bucketCounts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates an `AlignedData` instance, replacing all `undefined` entries
|
||||
* with explicit `null` values so uPlot treats them as gaps.
|
||||
*/
|
||||
export function replaceUndefinedWithNullInAlignedData(
|
||||
data: AlignedData,
|
||||
): AlignedData {
|
||||
const seriesList = data as (number | null | undefined)[][];
|
||||
for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex++) {
|
||||
for (
|
||||
let pointIndex = 0;
|
||||
pointIndex < seriesList[seriesIndex].length;
|
||||
pointIndex++
|
||||
) {
|
||||
if (seriesList[seriesIndex][pointIndex] === undefined) {
|
||||
seriesList[seriesIndex][pointIndex] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the first histogram series has a leading "empty" bin so that
|
||||
* all series line up visually when rendered as bars.
|
||||
*
|
||||
* - Prepends a new x-value (first x - `bucketSize`) to the first series.
|
||||
* - Prepends `null` to all subsequent series at the same index.
|
||||
*/
|
||||
export function prependNullBinToFirstHistogramSeries(
|
||||
alignedData: AlignedData,
|
||||
bucketSize: number,
|
||||
): void {
|
||||
const seriesList = alignedData as (number | null)[][];
|
||||
if (
|
||||
seriesList.length > 0 &&
|
||||
seriesList[0].length > 0 &&
|
||||
seriesList[0][0] !== null
|
||||
) {
|
||||
seriesList[0].unshift(seriesList[0][0] - bucketSize);
|
||||
for (let seriesIndex = 1; seriesIndex < seriesList.length; seriesIndex++) {
|
||||
seriesList[seriesIndex].unshift(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import {
|
||||
GraphVisibilityState,
|
||||
SeriesVisibilityItem,
|
||||
SeriesVisibilityState,
|
||||
} from '../types';
|
||||
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
|
||||
|
||||
/**
|
||||
* Retrieves the stored series visibility for a specific widget from localStorage by index.
|
||||
@@ -14,7 +10,7 @@ import {
|
||||
*/
|
||||
export function getStoredSeriesVisibility(
|
||||
widgetId: string,
|
||||
): SeriesVisibilityState | null {
|
||||
): SeriesVisibilityItem[] | null {
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
|
||||
@@ -29,10 +25,7 @@ export function getStoredSeriesVisibility(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
labels: widgetState.dataIndex.map((item) => item.label),
|
||||
visibility: widgetState.dataIndex.map((item) => item.show),
|
||||
};
|
||||
return widgetState.dataIndex;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
// If the stored data is malformed, remove it
|
||||
|
||||
@@ -477,47 +477,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
.opentelemetry-radio-container {
|
||||
.observability-tools-radio-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
width: 528px;
|
||||
|
||||
.opentelemetry-radio-group {
|
||||
width: 100%;
|
||||
.observability-tool-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
|
||||
.opentelemetry-radio-items-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
label {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opentelemetry-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
min-width: 258px;
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
button[role='radio'] {
|
||||
&[data-state='unchecked'] {
|
||||
border-color: var(--l3-border) !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.observability-tool-others-item {
|
||||
.onboarding-questionaire-other-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.migration-timeline-radio-container,
|
||||
.opentelemetry-radio-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
width: 528px;
|
||||
|
||||
.migration-timeline-radio-item,
|
||||
.opentelemetry-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
|
||||
label {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ant-radio {
|
||||
.ant-radio-inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
button[role='radio'] {
|
||||
&[data-state='unchecked'] {
|
||||
border-color: var(--l3-border) !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,27 +997,6 @@
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.opentelemetry-radio-container {
|
||||
.opentelemetry-radio-group {
|
||||
.opentelemetry-radio-items-wrapper {
|
||||
.opentelemetry-radio-item {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-radio {
|
||||
.ant-radio-inner {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-back-button {
|
||||
border-color: var(--text-vanilla-300);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Radio, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/es/radio';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupLabel,
|
||||
} from '@signozhq/radio-group';
|
||||
import { Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import editOrg from 'api/organization/editOrg';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import '../OnboardingQuestionaire.styles.scss';
|
||||
|
||||
export interface OrgData {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface OrgDetails {
|
||||
organisationName: string;
|
||||
usesObservability: boolean | null;
|
||||
observabilityTool: string | null;
|
||||
otherTool: string | null;
|
||||
usesOtel: boolean | null;
|
||||
migrationTimeline: string | null;
|
||||
}
|
||||
|
||||
interface OrgQuestionsProps {
|
||||
currentOrgData: OrgData | null;
|
||||
orgDetails: OrgDetails;
|
||||
onNext: (details: OrgDetails) => void;
|
||||
}
|
||||
@@ -45,19 +38,14 @@ const observabilityTools = {
|
||||
Others: 'Others',
|
||||
};
|
||||
|
||||
function OrgQuestions({
|
||||
currentOrgData,
|
||||
orgDetails,
|
||||
onNext,
|
||||
}: OrgQuestionsProps): JSX.Element {
|
||||
const { updateOrg } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const migrationTimelineOptions = {
|
||||
lessThanMonth: 'Less than a month',
|
||||
oneToThreeMonths: '1-3 months',
|
||||
greaterThanThreeMonths: 'Greater than 3 months',
|
||||
justExploring: 'Just exploring',
|
||||
};
|
||||
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
|
||||
const [organisationName, setOrganisationName] = useState<string>(
|
||||
orgDetails?.organisationName || '',
|
||||
);
|
||||
function OrgQuestions({ orgDetails, onNext }: OrgQuestionsProps): JSX.Element {
|
||||
const [observabilityTool, setObservabilityTool] = useState<string | null>(
|
||||
orgDetails?.observabilityTool || null,
|
||||
);
|
||||
@@ -66,92 +54,33 @@ function OrgQuestions({
|
||||
);
|
||||
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setOrganisationName(orgDetails.organisationName);
|
||||
}, [orgDetails.organisationName]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const [usesOtel, setUsesOtel] = useState<boolean | null>(orgDetails.usesOtel);
|
||||
const [migrationTimeline, setMigrationTimeline] = useState<string | null>(
|
||||
orgDetails?.migrationTimeline || null,
|
||||
);
|
||||
|
||||
const handleOrgNameUpdate = async (): Promise<void> => {
|
||||
const showMigrationQuestion =
|
||||
observabilityTool !== null && observabilityTool !== 'None';
|
||||
|
||||
const handleNext = (): void => {
|
||||
const usesObservability =
|
||||
!observabilityTool?.includes('None') && observabilityTool !== null;
|
||||
|
||||
/* Early bailout if orgData is not set or if the organisation name is not set or if the organisation name is empty or if the organisation name is the same as the one in the orgData */
|
||||
if (
|
||||
!currentOrgData ||
|
||||
!organisationName ||
|
||||
organisationName === '' ||
|
||||
orgDetails.organisationName === organisationName
|
||||
) {
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
migrationTimeline,
|
||||
});
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { statusCode, error } = await editOrg({
|
||||
displayName: organisationName,
|
||||
orgId: currentOrgData.id,
|
||||
});
|
||||
if (statusCode === 204) {
|
||||
updateOrg(currentOrgData?.id, organisationName);
|
||||
|
||||
logEvent('Org Onboarding: Org Name Updated', {
|
||||
organisationName,
|
||||
});
|
||||
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
} else {
|
||||
logEvent('Org Onboarding: Org Name Update Failed', {
|
||||
organisationName: orgDetails.organisationName,
|
||||
});
|
||||
|
||||
notifications.error({
|
||||
message:
|
||||
error ||
|
||||
t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
onNext({
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
migrationTimeline,
|
||||
});
|
||||
};
|
||||
|
||||
const isValidUsesObservability = (): boolean => {
|
||||
@@ -173,22 +102,29 @@ function OrgQuestions({
|
||||
|
||||
useEffect(() => {
|
||||
const isValidObservability = isValidUsesObservability();
|
||||
const isMigrationValid = !showMigrationQuestion || migrationTimeline !== null;
|
||||
|
||||
if (organisationName !== '' && usesOtel !== null && isValidObservability) {
|
||||
if (usesOtel !== null && isValidObservability && isMigrationValid) {
|
||||
setIsNextDisabled(false);
|
||||
} else {
|
||||
setIsNextDisabled(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organisationName, usesOtel, observabilityTool, otherTool]);
|
||||
}, [
|
||||
usesOtel,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
migrationTimeline,
|
||||
showMigrationQuestion,
|
||||
]);
|
||||
|
||||
const createObservabilityToolHandler = (tool: string) => (
|
||||
checked: boolean,
|
||||
): void => {
|
||||
if (checked) {
|
||||
setObservabilityTool(tool);
|
||||
} else if (observabilityTool === tool) {
|
||||
setObservabilityTool(null);
|
||||
const handleObservabilityToolChange = (value: string): void => {
|
||||
setObservabilityTool(value);
|
||||
if (value !== 'Others') {
|
||||
setOtherTool('');
|
||||
}
|
||||
if (value === 'None') {
|
||||
setMigrationTimeline(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,10 +132,6 @@ function OrgQuestions({
|
||||
setUsesOtel(value === 'yes');
|
||||
};
|
||||
|
||||
const handleOnNext = (): void => {
|
||||
handleOrgNameUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
<div className="onboarding-header-section">
|
||||
@@ -214,40 +146,24 @@ function OrgQuestions({
|
||||
|
||||
<div className="questions-form-container">
|
||||
<div className="questions-form">
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="organisationName">
|
||||
Name of your company
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="organisationName"
|
||||
id="organisationName"
|
||||
placeholder="e.g. Simpsonville"
|
||||
autoComplete="off"
|
||||
value={organisationName}
|
||||
onChange={(e): void => setOrganisationName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="observabilityTool">
|
||||
Which observability tool do you currently use?
|
||||
</label>
|
||||
<div className="observability-tools-checkbox-container">
|
||||
<RadioGroup
|
||||
value={observabilityTool || ''}
|
||||
onValueChange={handleObservabilityToolChange}
|
||||
className="observability-tools-radio-container"
|
||||
>
|
||||
{Object.entries(observabilityTools).map(([tool, label]) => {
|
||||
if (tool === 'Others') {
|
||||
return (
|
||||
<div
|
||||
key={tool}
|
||||
className="checkbox-item observability-tool-checkbox-item observability-tool-others-item"
|
||||
className="radio-item observability-tool-radio-item observability-tool-others-item"
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${tool}`}
|
||||
checked={observabilityTool === tool}
|
||||
onCheckedChange={createObservabilityToolHandler(tool)}
|
||||
labelName={observabilityTool === 'Others' ? '' : label}
|
||||
/>
|
||||
{observabilityTool === 'Others' && (
|
||||
<RadioGroupItem value={tool} id={`radio-${tool}`} />
|
||||
{observabilityTool === 'Others' ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
@@ -256,55 +172,60 @@ function OrgQuestions({
|
||||
autoFocus
|
||||
onChange={(e): void => setOtherTool(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<RadioGroupLabel htmlFor={`radio-${tool}`}>{label}</RadioGroupLabel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tool}
|
||||
className="checkbox-item observability-tool-checkbox-item"
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${tool}`}
|
||||
checked={observabilityTool === tool}
|
||||
onCheckedChange={createObservabilityToolHandler(tool)}
|
||||
labelName={label}
|
||||
/>
|
||||
<div key={tool} className="radio-item observability-tool-radio-item">
|
||||
<RadioGroupItem value={tool} id={`radio-${tool}`} />
|
||||
<RadioGroupLabel htmlFor={`radio-${tool}`}>{label}</RadioGroupLabel>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{showMigrationQuestion && (
|
||||
<div className="form-group">
|
||||
<div className="question">
|
||||
What is your timeline for migrating to SigNoz?
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={migrationTimeline || ''}
|
||||
onValueChange={setMigrationTimeline}
|
||||
className="migration-timeline-radio-container"
|
||||
>
|
||||
{Object.entries(migrationTimelineOptions).map(([key, label]) => (
|
||||
<div key={key} className="radio-item migration-timeline-radio-item">
|
||||
<RadioGroupItem value={key} id={`radio-migration-${key}`} />
|
||||
<RadioGroupLabel htmlFor={`radio-migration-${key}`}>
|
||||
{label}
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="question">Do you already use OpenTelemetry?</div>
|
||||
<div className="opentelemetry-radio-container">
|
||||
<Radio.Group
|
||||
value={((): string | undefined => {
|
||||
if (usesOtel === true) {
|
||||
return 'yes';
|
||||
}
|
||||
if (usesOtel === false) {
|
||||
return 'no';
|
||||
}
|
||||
return undefined;
|
||||
})()}
|
||||
onChange={(e: RadioChangeEvent): void =>
|
||||
handleOtelChange(e.target.value)
|
||||
}
|
||||
className="opentelemetry-radio-group"
|
||||
>
|
||||
<div className="opentelemetry-radio-items-wrapper">
|
||||
<Radio value="yes" className="opentelemetry-radio-item">
|
||||
Yes
|
||||
</Radio>
|
||||
<Radio value="no" className="opentelemetry-radio-item">
|
||||
No
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={usesOtel === true ? 'yes' : usesOtel === false ? 'no' : ''}
|
||||
onValueChange={handleOtelChange}
|
||||
className="opentelemetry-radio-container"
|
||||
>
|
||||
<div className="radio-item opentelemetry-radio-item">
|
||||
<RadioGroupItem value="yes" id="radio-otel-yes" />
|
||||
<RadioGroupLabel htmlFor="radio-otel-yes">Yes</RadioGroupLabel>
|
||||
</div>
|
||||
<div className="radio-item opentelemetry-radio-item">
|
||||
<RadioGroupItem value="no" id="radio-otel-no" />
|
||||
<RadioGroupLabel htmlFor="radio-otel-no">No</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,15 +233,9 @@ function OrgQuestions({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${isNextDisabled ? 'disabled' : ''}`}
|
||||
onClick={handleOnNext}
|
||||
onClick={handleNext}
|
||||
disabled={isNextDisabled}
|
||||
suffixIcon={
|
||||
isLoading ? (
|
||||
<Loader2 className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
expect(screen.getByText(/welcome to signoz cloud/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name of your company/i)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(/which observability tool do you currently use/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -86,15 +86,12 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
const datadogCheckbox = screen.getByLabelText(/datadog/i);
|
||||
await user.click(datadogCheckbox);
|
||||
|
||||
const otelYes = screen.getByRole('radio', { name: /yes/i });
|
||||
await user.click(otelYes);
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
@@ -112,15 +109,38 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows migration timeline options only when specific observability tools are selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Initially not visible
|
||||
expect(
|
||||
screen.queryByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const datadogCheckbox = screen.getByLabelText(/datadog/i);
|
||||
await user.click(datadogCheckbox);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Not visible when None is selected
|
||||
const noneCheckbox = screen.getByLabelText(/none\/starting fresh/i);
|
||||
await user.click(noneCheckbox);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('proceeds to step 2 when next is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
@@ -137,11 +157,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -157,11 +176,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -175,11 +193,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -203,11 +220,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -232,11 +248,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate through steps 1 and 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -267,11 +282,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 3
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -290,40 +304,4 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles organization update error gracefully', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(EDIT_ORG_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update organization',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
|
||||
// Component should still be functional
|
||||
await waitFor(() => {
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import InviteTeamMembers from './InviteTeamMembers/InviteTeamMembers';
|
||||
import OptimiseSignozNeeds, {
|
||||
OptimiseSignozDetails,
|
||||
} from './OptimiseSignozNeeds/OptimiseSignozNeeds';
|
||||
import OrgQuestions, { OrgData, OrgDetails } from './OrgQuestions/OrgQuestions';
|
||||
import OrgQuestions, { OrgDetails } from './OrgQuestions/OrgQuestions';
|
||||
|
||||
import './OnboardingQuestionaire.styles.scss';
|
||||
|
||||
@@ -37,11 +37,11 @@ export const showErrorNotification = (
|
||||
};
|
||||
|
||||
const INITIAL_ORG_DETAILS: OrgDetails = {
|
||||
organisationName: '',
|
||||
usesObservability: true,
|
||||
observabilityTool: '',
|
||||
otherTool: '',
|
||||
usesOtel: null,
|
||||
migrationTimeline: null,
|
||||
};
|
||||
|
||||
const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
|
||||
@@ -79,25 +79,11 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
InviteTeamMembersProps[] | null
|
||||
>(null);
|
||||
|
||||
const [currentOrgData, setCurrentOrgData] = useState<OrgData | null>(null);
|
||||
|
||||
const [
|
||||
updatingOrgOnboardingStatus,
|
||||
setUpdatingOrgOnboardingStatus,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (org) {
|
||||
setCurrentOrgData(org[0]);
|
||||
|
||||
setOrgDetails({
|
||||
...orgDetails,
|
||||
organisationName: org[0].displayName,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [org]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Org Onboarding: Started', {
|
||||
org_id: org?.[0]?.id,
|
||||
@@ -175,6 +161,7 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
? (orgDetails?.otherTool as string)
|
||||
: (orgDetails?.observabilityTool as string),
|
||||
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
|
||||
timeline_for_migrating_to_signoz: orgDetails?.migrationTimeline as string,
|
||||
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
|
||||
'Others',
|
||||
)
|
||||
@@ -208,7 +195,6 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
<div className="onboarding-questionaire-content">
|
||||
{currentStep === 1 && (
|
||||
<OrgQuestions
|
||||
currentOrgData={currentOrgData}
|
||||
orgDetails={{
|
||||
...orgDetails,
|
||||
usesOtel: orgDetails.usesOtel ?? null,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import BarPanel from 'container/DashboardContainer/visualization/panels/BarPanel/BarPanel';
|
||||
import HistogramPanel from 'container/DashboardContainer/visualization/panels/HistogramPanel/HistogramPanel';
|
||||
|
||||
import TimeSeriesPanel from '../DashboardContainer/visualization/panels/TimeSeriesPanel/TimeSeriesPanel';
|
||||
import HistogramPanelWrapper from './HistogramPanelWrapper';
|
||||
import ListPanelWrapper from './ListPanelWrapper';
|
||||
import PiePanelWrapper from './PiePanelWrapper';
|
||||
import TablePanelWrapper from './TablePanelWrapper';
|
||||
import UplotPanelWrapper from './UplotPanelWrapper';
|
||||
import ValuePanelWrapper from './ValuePanelWrapper';
|
||||
|
||||
export const PanelTypeVsPanelWrapper = {
|
||||
@@ -16,8 +16,8 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: UplotPanelWrapper,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
};
|
||||
|
||||
export const DEFAULT_BUCKET_COUNT = 30;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { HistogramTooltipProps } from '../types';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default function HistogramTooltip(
|
||||
props: HistogramTooltipProps,
|
||||
): JSX.Element {
|
||||
return <Tooltip {...props} showTooltipHeader={false} />;
|
||||
}
|
||||
@@ -16,12 +16,16 @@ export default function Tooltip({
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const tooltipContent = content ?? [];
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!showTooltipHeader) {
|
||||
return null;
|
||||
}
|
||||
const data = uPlotInstance.data;
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
if (cursorIdx == null) {
|
||||
@@ -30,7 +34,12 @@ export default function Tooltip({
|
||||
return dayjs(data[0][cursorIdx] * 1000)
|
||||
.tz(timezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
|
||||
}, [
|
||||
timezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -39,9 +48,11 @@ export default function Tooltip({
|
||||
isDarkMode ? 'darkMode' : 'lightMode',
|
||||
)}
|
||||
>
|
||||
<div className="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
{showTooltipHeader && (
|
||||
<div className="uplot-tooltip-header">
|
||||
<span>{headerTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: Math.min(
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface TooltipRenderArgs {
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
showTooltipHeader?: boolean;
|
||||
timezone: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
@@ -74,7 +75,14 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type TooltipProps = TimeSeriesTooltipProps | BarTooltipProps;
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps,
|
||||
TooltipRenderArgs {}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
| HistogramTooltipProps;
|
||||
|
||||
export enum LegendPosition {
|
||||
BOTTOM = 'bottom',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { SeriesVisibilityItem } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
@@ -238,7 +238,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
/**
|
||||
* Returns stored series visibility by index from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
|
||||
*/
|
||||
private getStoredVisibility(): SeriesVisibilityState | null {
|
||||
private getStoredVisibility(): SeriesVisibilityItem[] | null {
|
||||
if (
|
||||
this.widgetId &&
|
||||
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
|
||||
@@ -248,14 +248,98 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive visibility resolution state from stored preferences and current series:
|
||||
* - visibleStoredLabels: labels that should always be visible
|
||||
* - hiddenStoredLabels: labels that should always be hidden
|
||||
* - hasActivePreference: whether a "mix" preference applies to new labels
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
private getVisibilityResolutionState(): {
|
||||
visibleStoredLabels: Set<string>;
|
||||
hiddenStoredLabels: Set<string>;
|
||||
hasActivePreference: boolean;
|
||||
} {
|
||||
const seriesVisibilityState = this.getStoredVisibility();
|
||||
if (!seriesVisibilityState || seriesVisibilityState.length === 0) {
|
||||
return {
|
||||
visibleStoredLabels: new Set<string>(),
|
||||
hiddenStoredLabels: new Set<string>(),
|
||||
hasActivePreference: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Single pass over stored items to derive:
|
||||
// - visibleStoredLabels: any label that is ever stored as visible
|
||||
// - hiddenStoredLabels: labels that are only ever stored as hidden
|
||||
// - hasMixPreference: there is at least one visible and one hidden entry
|
||||
const visibleStoredLabels = new Set<string>();
|
||||
const hiddenStoredLabels = new Set<string>();
|
||||
let hasAnyVisible = false;
|
||||
let hasAnyHidden = false;
|
||||
|
||||
for (const { label, show } of seriesVisibilityState) {
|
||||
if (show) {
|
||||
hasAnyVisible = true;
|
||||
visibleStoredLabels.add(label);
|
||||
// If a label is ever visible, it should not be treated as "only hidden"
|
||||
if (hiddenStoredLabels.has(label)) {
|
||||
hiddenStoredLabels.delete(label);
|
||||
}
|
||||
} else {
|
||||
hasAnyHidden = true;
|
||||
// Only track as hidden if we have not already seen it as visible
|
||||
if (!visibleStoredLabels.has(label)) {
|
||||
hiddenStoredLabels.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasMixPreference = hasAnyVisible && hasAnyHidden;
|
||||
|
||||
// Current series labels in this chart.
|
||||
const currentSeriesLabels = this.series.map(
|
||||
(s: UPlotSeriesBuilder) => s.getConfig().label ?? '',
|
||||
);
|
||||
|
||||
// Check if any stored "visible" label exists in the current series list.
|
||||
const hasVisibleIntersection =
|
||||
visibleStoredLabels.size > 0 &&
|
||||
currentSeriesLabels.some((label) => visibleStoredLabels.has(label));
|
||||
|
||||
// Active preference only when there is a mix AND at least one visible
|
||||
// stored label is present in the current series list.
|
||||
const hasActivePreference = hasMixPreference && hasVisibleIntersection;
|
||||
|
||||
// We apply stored visibility in two cases:
|
||||
// - There is an active preference (mix + intersection), OR
|
||||
// - There is no mix (all true or all false) – preserve legacy behavior.
|
||||
const shouldApplyStoredVisibility = !hasMixPreference || hasActivePreference;
|
||||
|
||||
if (!shouldApplyStoredVisibility) {
|
||||
return {
|
||||
visibleStoredLabels: new Set<string>(),
|
||||
hiddenStoredLabels: new Set<string>(),
|
||||
hasActivePreference,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get legend items with visibility state restored from localStorage if available
|
||||
*/
|
||||
getLegendItems(): Record<number, LegendItem> {
|
||||
const seriesVisibilityState = this.getStoredVisibility();
|
||||
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
|
||||
(show) => !show,
|
||||
);
|
||||
const {
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
} = this.getVisibilityResolutionState();
|
||||
|
||||
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
|
||||
const seriesConfig = s.getConfig();
|
||||
@@ -263,11 +347,11 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
// +1 because uPlot series 0 is x-axis/time; data series are at 1, 2, ... (also matches stored visibility[0]=time, visibility[1]=first data, ...)
|
||||
const seriesIndex = index + 1;
|
||||
const show = resolveSeriesVisibility({
|
||||
seriesIndex,
|
||||
seriesShow: seriesConfig.show,
|
||||
seriesLabel: label,
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
});
|
||||
|
||||
acc[seriesIndex] = {
|
||||
@@ -296,22 +380,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
};
|
||||
|
||||
const seriesVisibilityState = this.getStoredVisibility();
|
||||
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
|
||||
(show) => !show,
|
||||
);
|
||||
const {
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
} = this.getVisibilityResolutionState();
|
||||
|
||||
config.series = [
|
||||
{ value: (): string => '' }, // Base series for timestamp
|
||||
...this.series.map((s, index) => {
|
||||
{ value: (): string => '', label: 'Timestamp' }, // Base series for timestamp
|
||||
...this.series.map((s) => {
|
||||
const series = s.getConfig();
|
||||
// Stored visibility[0] is x-axis/time; data series start at visibility[1]
|
||||
const visible = resolveSeriesVisibility({
|
||||
seriesIndex: index + 1,
|
||||
seriesShow: series.show,
|
||||
seriesLabel: series.label ?? '',
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
});
|
||||
return {
|
||||
...series,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
}: {
|
||||
resolvedLineColor: string;
|
||||
}): Partial<Series> {
|
||||
const { lineWidth, lineStyle, lineCap } = this.props;
|
||||
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? 2,
|
||||
@@ -63,8 +63,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
lineConfig.cap = lineCap;
|
||||
}
|
||||
|
||||
if (this.props.panelType === PANEL_TYPES.BAR) {
|
||||
if (fillColor) {
|
||||
lineConfig.fill = fillColor;
|
||||
} else if (this.props.panelType === PANEL_TYPES.BAR) {
|
||||
lineConfig.fill = resolvedLineColor;
|
||||
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
|
||||
lineConfig.fill = `${resolvedLineColor}40`;
|
||||
}
|
||||
|
||||
return lineConfig;
|
||||
@@ -147,6 +151,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
pointsConfig.show = false;
|
||||
} else if (showPoints === VisibilityMode.Always) {
|
||||
pointsConfig.show = true;
|
||||
} else {
|
||||
pointsConfig.show = false; // default to hidden
|
||||
}
|
||||
|
||||
return pointsConfig;
|
||||
|
||||
@@ -186,11 +186,10 @@ describe('UPlotConfigBuilder', () => {
|
||||
});
|
||||
|
||||
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
|
||||
// Index 0 = x-axis/time; indices 1,2 = data series (Requests, Errors). resolveSeriesVisibility matches by seriesIndex + seriesLabel.
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue({
|
||||
labels: ['x-axis', 'Requests', 'Errors'],
|
||||
visibility: [true, true, false],
|
||||
});
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([
|
||||
{ label: 'Requests', show: true },
|
||||
{ label: 'Errors', show: false },
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
@@ -202,7 +201,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// When any series is hidden, legend visibility is driven by the stored map
|
||||
// When any series is hidden, visibility is driven by stored label-based preferences
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
|
||||
@@ -213,6 +212,109 @@ describe('UPlotConfigBuilder', () => {
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('hides new series by default when there is a mixed preference and a visible label matches current series', () => {
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([
|
||||
{ label: 'Requests', show: true },
|
||||
{ label: 'Errors', show: false },
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Latency' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// Stored labels: Requests (visible), Errors (hidden).
|
||||
// New label "Latency" should be hidden because there is a mixed preference
|
||||
// and "Requests" (a visible stored label) is present in the current series.
|
||||
expect(legendItems[1].label).toBe('Requests');
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].label).toBe('Errors');
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
expect(legendItems[3].label).toBe('Latency');
|
||||
expect(legendItems[3].show).toBe(false);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries, thirdSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.label).toBe('Requests');
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.label).toBe('Errors');
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
expect(thirdSeries?.label).toBe('Latency');
|
||||
expect(thirdSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('shows all series when there is a mixed preference but no visible stored labels match current series', () => {
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([
|
||||
{ label: 'StoredVisible', show: true },
|
||||
{ label: 'StoredHidden', show: false },
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
// None of these labels intersect with the stored visible label "StoredVisible"
|
||||
builder.addSeries(createSeriesProps({ label: 'CPU' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Memory' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// Mixed preference exists in storage, but since no visible labels intersect
|
||||
// with current series, stored preferences are ignored and all are visible.
|
||||
expect(legendItems[1].label).toBe('CPU');
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].label).toBe('Memory');
|
||||
expect(legendItems[2].show).toBe(true);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.label).toBe('CPU');
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.label).toBe('Memory');
|
||||
expect(secondSeries?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('treats duplicate labels as visible when any stored entry for that label is visible', () => {
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-dup',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
// Two series with the same label; both should be visible because at least
|
||||
// one stored entry for "CPU" is visible.
|
||||
builder.addSeries(createSeriesProps({ label: 'CPU' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'CPU' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
expect(legendItems[1].label).toBe('CPU');
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].label).toBe('CPU');
|
||||
expect(legendItems[2].show).toBe(true);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.label).toBe('CPU');
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.label).toBe('CPU');
|
||||
expect(secondSeries?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
|
||||
@@ -175,6 +175,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
pointsBuilder?: Series.Points.Show;
|
||||
show?: boolean;
|
||||
spanGaps?: boolean;
|
||||
fillColor?: string;
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
}
|
||||
|
||||
@@ -11,22 +11,6 @@ import { Threshold } from '../hooks/types';
|
||||
import { findMinMaxThresholdValues } from './threshold';
|
||||
import { LogScaleLimits, RangeFunctionParams } from './types';
|
||||
|
||||
/**
|
||||
* Rounds a number down to the nearest multiple of incr.
|
||||
* Used for linear scale min so the axis starts on a clean tick.
|
||||
*/
|
||||
export function incrRoundDn(num: number, incr: number): number {
|
||||
return Math.floor(num / incr) * incr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a number up to the nearest multiple of incr.
|
||||
* Used for linear scale max so the axis ends on a clean tick.
|
||||
*/
|
||||
export function incrRoundUp(num: number, incr: number): number {
|
||||
return Math.ceil(num / incr) * incr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps min/max/softMin/softMax to valid log-scale values (powers of logBase).
|
||||
* Only applies when distribution is logarithmic; otherwise returns limits unchanged.
|
||||
@@ -213,25 +197,6 @@ function getLogScaleRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps linear scale min down and max up to whole numbers so axis bounds are clean.
|
||||
*/
|
||||
function roundLinearRange(minMax: Range.MinMax): Range.MinMax {
|
||||
const [currentMin, currentMax] = minMax;
|
||||
let roundedMin = currentMin;
|
||||
let roundedMax = currentMax;
|
||||
|
||||
if (roundedMin != null) {
|
||||
roundedMin = incrRoundDn(roundedMin, 1);
|
||||
}
|
||||
|
||||
if (roundedMax != null) {
|
||||
roundedMax = incrRoundUp(roundedMax, 1);
|
||||
}
|
||||
|
||||
return [roundedMin, roundedMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
|
||||
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
|
||||
@@ -330,7 +295,6 @@ export function createRangeFunction(
|
||||
|
||||
if (scale.distr === 1) {
|
||||
minMax = getLinearScaleRange(minMax, params, dataMin, dataMax);
|
||||
minMax = roundLinearRange(minMax);
|
||||
} else if (scale.distr === 3) {
|
||||
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
/**
|
||||
* Resolve the visibility of a single series based on:
|
||||
* - Stored per-series visibility (when applicable)
|
||||
* - Whether there is an "active preference" (mix of visible/hidden that matches current series)
|
||||
* - The series' own default show flag
|
||||
*/
|
||||
export function resolveSeriesVisibility({
|
||||
seriesIndex,
|
||||
seriesShow,
|
||||
seriesLabel,
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
visibleStoredLabels,
|
||||
hiddenStoredLabels,
|
||||
hasActivePreference,
|
||||
}: {
|
||||
seriesIndex: number;
|
||||
seriesShow: boolean | undefined | null;
|
||||
seriesLabel: string;
|
||||
seriesVisibilityState: SeriesVisibilityState | null;
|
||||
isAnySeriesHidden: boolean;
|
||||
visibleStoredLabels: Set<string> | null;
|
||||
hiddenStoredLabels: Set<string> | null;
|
||||
hasActivePreference: boolean;
|
||||
}): boolean {
|
||||
if (
|
||||
isAnySeriesHidden &&
|
||||
seriesVisibilityState?.visibility &&
|
||||
seriesVisibilityState.labels.length > seriesIndex &&
|
||||
seriesVisibilityState.labels[seriesIndex] === seriesLabel
|
||||
) {
|
||||
return seriesVisibilityState.visibility[seriesIndex] ?? false;
|
||||
const isStoredVisible = !!visibleStoredLabels?.has(seriesLabel);
|
||||
const isStoredHidden = !!hiddenStoredLabels?.has(seriesLabel);
|
||||
|
||||
// If the label is explicitly stored as visible, always show it.
|
||||
if (isStoredVisible) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the label is explicitly stored as hidden (and never stored as visible),
|
||||
// always hide it.
|
||||
if (isStoredHidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// "Active preference" means:
|
||||
// - There is a mix of visible/hidden in storage, AND
|
||||
// - At least one stored *visible* label exists in the current series list.
|
||||
// For such a preference, any new/unknown series should be hidden by default.
|
||||
if (hasActivePreference) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise fall back to the series' own config or show by default.
|
||||
return seriesShow ?? true;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,78 @@ describe('dashboardVariablesStore', () => {
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and selectedValue=undefined as having a value', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: undefined,
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and empty string selectedValue as having a value', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: '',
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat DYNAMIC variable with allSelected=true and empty array selectedValue as having a value', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
dyn1: createVariable({
|
||||
name: 'dyn1',
|
||||
type: 'DYNAMIC',
|
||||
order: 0,
|
||||
selectedValue: [] as any,
|
||||
allSelected: true,
|
||||
}),
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
order: 1,
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { doAllVariablesHaveValuesSelected } = getVariableDependencyContext();
|
||||
expect(doAllVariablesHaveValuesSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('should report false when a DYNAMIC variable has empty selectedValue and allSelected is not true', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
|
||||
@@ -76,7 +76,7 @@ export function getVariableDependencyContext(): VariableFetchContext {
|
||||
(variable) => {
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.selectedValue === null &&
|
||||
(variable.selectedValue === null || isEmpty(variable.selectedValue)) &&
|
||||
variable.allSelected === true
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -7,4 +7,5 @@ export interface UpdateProfileProps {
|
||||
number_of_services: number;
|
||||
number_of_hosts: number;
|
||||
where_did_you_discover_signoz: string;
|
||||
timeline_for_migrating_to_signoz: string;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,33 @@
|
||||
* Example: 1.5 → 2, 1.49 → 1
|
||||
*/
|
||||
export const roundHalfUp = (value: number): number => Math.floor(value + 0.5);
|
||||
|
||||
/**
|
||||
* Rounds a number down to the nearest multiple of incr.
|
||||
* Used for linear scale min so the axis starts on a clean tick.
|
||||
*/
|
||||
export function incrRoundDn(num: number, incr: number): number {
|
||||
return Math.floor(num / incr) * incr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a number up to the nearest multiple of incr.
|
||||
* Used for linear scale max so the axis ends on a clean tick.
|
||||
*/
|
||||
export function incrRoundUp(num: number, incr: number): number {
|
||||
return Math.ceil(num / incr) * incr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a number to the nearest multiple of 10^dec.
|
||||
* Used for decimal precision.
|
||||
*/
|
||||
export function roundDecimals(val: number, dec = 0): number {
|
||||
if (Number.isInteger(val)) {
|
||||
return val;
|
||||
}
|
||||
|
||||
const p = 10 ** dec;
|
||||
const n = val * p * (1 + Number.EPSILON);
|
||||
return Math.round(n) / p;
|
||||
}
|
||||
|
||||
@@ -4073,6 +4073,16 @@
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
|
||||
"@radix-ui/react-collection@1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec"
|
||||
integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae"
|
||||
@@ -4159,6 +4169,11 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-direction@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14"
|
||||
integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz#35b7826fa262fd84370faef310e627161dffa76b"
|
||||
@@ -4387,6 +4402,22 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.2.4"
|
||||
|
||||
"@radix-ui/react-radio-group@^1.3.4":
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz#93f102b5b948d602c2f2adb1bc5c347cbaf64bd9"
|
||||
integrity sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
"@radix-ui/react-use-previous" "1.1.1"
|
||||
"@radix-ui/react-use-size" "1.1.1"
|
||||
|
||||
"@radix-ui/react-roving-focus@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
|
||||
@@ -4403,6 +4434,21 @@
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
|
||||
"@radix-ui/react-roving-focus@1.1.11":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz#ef54384b7361afc6480dcf9907ef2fedb5080fd9"
|
||||
integrity sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-collection" "1.1.7"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-slot@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.0.tgz#7fa805b99891dea1e862d8f8fbe07f4d6d0fd698"
|
||||
@@ -5075,6 +5121,20 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/radio-group@0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/radio-group/-/radio-group-0.0.2.tgz#4b13567bfee2645226f2cf41f261bbb288e1be4b"
|
||||
integrity sha512-ahykmA5hPujOC964CFveMlQ12tWSyut2CUiFRqT1QxRkOLS2R44Qn2hh2psqJJ18JMX/24ZYCAIh9Bdd5XW+7g==
|
||||
dependencies:
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-radio-group" "^1.3.4"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
lucide-react "^0.445.0"
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/resizable@0.0.0":
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/resizable/-/resizable-0.0.0.tgz#a517818b9f9bcdaeafc55ae134be86522bc90e9f"
|
||||
|
||||
@@ -3,9 +3,8 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureInterpolationEnabled = featuretypes.MustNewName("interpolation_enabled")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -18,14 +17,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureInterpolationEnabled,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether to enable interpolation",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureKafkaSpanEval,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
|
||||
44
pkg/http/middleware/recovery.go
Normal file
44
pkg/http/middleware/recovery.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -30,3 +30,7 @@ func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organiz
|
||||
|
||||
return module.store.ListByKeyRange(ctx, start, end)
|
||||
}
|
||||
|
||||
func (module *getter) GetByName(ctx context.Context, name string) (*types.Organization, error) {
|
||||
return module.store.GetByName(ctx, name)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,22 @@ func (store *store) Get(ctx context.Context, id valuer.UUID) (*types.Organizatio
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, name string) (*types.Organization, error) {
|
||||
organization := new(types.Organization)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(organization).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "organization with name %s does not exist", name)
|
||||
}
|
||||
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
|
||||
organizations := make([]*types.Organization, 0)
|
||||
err := store.
|
||||
|
||||
@@ -14,6 +14,9 @@ type Getter interface {
|
||||
|
||||
// ListByOwnedKeyRange gets all the organizations owned by the instance
|
||||
ListByOwnedKeyRange(context.Context) ([]*types.Organization, error)
|
||||
|
||||
// Gets the organization by name
|
||||
GetByName(context.Context, string) (*types.Organization, error)
|
||||
}
|
||||
|
||||
type Setter interface {
|
||||
|
||||
@@ -151,6 +151,10 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := user.ErrIfRoot(); err != nil {
|
||||
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
|
||||
}
|
||||
|
||||
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, user.OrgID, user.Email, user.Role), map[string]string{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -5,11 +5,26 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Password PasswordConfig `mapstructure:"password"`
|
||||
Root RootConfig `mapstructure:"root"`
|
||||
}
|
||||
|
||||
type RootConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Email valuer.Email `mapstructure:"email"`
|
||||
Password string `mapstructure:"password"`
|
||||
Org OrgConfig `mapstructure:"org"`
|
||||
}
|
||||
|
||||
type OrgConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
Reset ResetConfig `mapstructure:"reset"`
|
||||
}
|
||||
@@ -31,6 +46,12 @@ func newConfig() factory.Config {
|
||||
MaxTokenLifetime: 6 * time.Hour,
|
||||
},
|
||||
},
|
||||
Root: RootConfig{
|
||||
Enabled: false,
|
||||
Org: OrgConfig{
|
||||
Name: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +60,17 @@ func (c Config) Validate() error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::reset::max_token_lifetime must be positive")
|
||||
}
|
||||
|
||||
if c.Root.Enabled {
|
||||
if c.Root.Email.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::email is required when root user is enabled")
|
||||
}
|
||||
if c.Root.Password == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password is required when root user is enabled")
|
||||
}
|
||||
if !types.IsPasswordValid(c.Root.Password) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password does not meet password requirements")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ func NewGetter(store types.UserStore) user.Getter {
|
||||
return &getter{store: store}
|
||||
}
|
||||
|
||||
func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
|
||||
return module.store.GetRootUserByOrgID(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *getter) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
|
||||
users, err := module.store.ListUsersByOrgID(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -103,6 +104,12 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingUser != nil {
|
||||
if err := existingUser.ErrIfRoot(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot send invite to root user")
|
||||
}
|
||||
}
|
||||
|
||||
if existingUser != nil {
|
||||
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email")
|
||||
}
|
||||
@@ -167,7 +174,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
|
||||
@@ -202,27 +209,21 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := existingUser.ErrIfRoot(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot update root user")
|
||||
}
|
||||
|
||||
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// only displayName, role can be updated
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = existingUser.DisplayName
|
||||
}
|
||||
|
||||
if user.Role == "" {
|
||||
user.Role = existingUser.Role
|
||||
}
|
||||
|
||||
if user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
|
||||
if user.Role != "" && user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
|
||||
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
|
||||
}
|
||||
|
||||
// Make sure that th e request is not demoting the last admin user.
|
||||
// also an admin user can only change role of their own or other user
|
||||
if user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
|
||||
// Make sure that the request is not demoting the last admin user.
|
||||
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
|
||||
adminUsers, err := m.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,7 +234,7 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
|
||||
}
|
||||
}
|
||||
|
||||
if user.Role != existingUser.Role {
|
||||
if user.Role != "" && user.Role != existingUser.Role {
|
||||
err = m.authz.ModifyGrant(ctx,
|
||||
orgID,
|
||||
roletypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role),
|
||||
@@ -245,23 +246,28 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
|
||||
}
|
||||
}
|
||||
|
||||
user.UpdatedAt = time.Now()
|
||||
updatedUser, err := m.store.UpdateUser(ctx, orgID, id, user)
|
||||
if err != nil {
|
||||
existingUser.Update(user.DisplayName, user.Role)
|
||||
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
traits := types.NewTraitsFromUser(updatedUser)
|
||||
m.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traits)
|
||||
return existingUser, nil
|
||||
}
|
||||
|
||||
traits["updated_by"] = updatedBy
|
||||
m.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Updated", traits)
|
||||
|
||||
if err := m.tokenizer.DeleteIdentity(ctx, valuer.MustNewUUID(id)); err != nil {
|
||||
return nil, err
|
||||
func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
|
||||
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updatedUser, nil
|
||||
traits := types.NewTraitsFromUser(user)
|
||||
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traits)
|
||||
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Updated", traits)
|
||||
|
||||
if err := module.tokenizer.DeleteIdentity(ctx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error {
|
||||
@@ -270,7 +276,11 @@ 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 err := user.ErrIfRoot(); err != nil {
|
||||
return errors.WithAdditionalf(err, "cannot delete root user")
|
||||
}
|
||||
|
||||
if slices.Contains(integrationstypes.IntegrationUserEmails, user.Email) {
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
|
||||
}
|
||||
|
||||
@@ -284,7 +294,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
|
||||
@@ -302,6 +312,15 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
}
|
||||
|
||||
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
|
||||
user, err := module.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := user.ErrIfRoot(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot reset password for root user")
|
||||
}
|
||||
|
||||
password, err := module.store.GetPasswordByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
@@ -364,6 +383,10 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
|
||||
return err
|
||||
}
|
||||
|
||||
if err := user.ErrIfRoot(); err != nil {
|
||||
return errors.WithAdditionalf(err, "cannot reset password for root user")
|
||||
}
|
||||
|
||||
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
module.settings.Logger().ErrorContext(ctx, "failed to create reset password token", "error", err)
|
||||
@@ -407,6 +430,15 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := user.ErrIfRoot(); err != nil {
|
||||
return errors.WithAdditionalf(err, "cannot reset password for root user")
|
||||
}
|
||||
|
||||
if err := password.Update(passwd); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -415,6 +447,15 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
|
||||
}
|
||||
|
||||
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
|
||||
user, err := module.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := user.ErrIfRoot(); err != nil {
|
||||
return errors.WithAdditionalf(err, "cannot change password for root user")
|
||||
}
|
||||
|
||||
password, err := module.store.GetPasswordByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -476,7 +517,7 @@ func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UU
|
||||
}
|
||||
|
||||
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, passwd string) (*types.User, error) {
|
||||
user, err := types.NewUser(name, email, types.RoleAdmin, organization.ID)
|
||||
user, err := types.NewRootUser(name, email, organization.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
187
pkg/modules/user/impluser/service.go
Normal file
187
pkg/modules/user/impluser/service.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package impluser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"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/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store types.UserStore
|
||||
module user.Module
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
config user.RootConfig
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewService(
|
||||
providerSettings factory.ProviderSettings,
|
||||
store types.UserStore,
|
||||
module user.Module,
|
||||
orgGetter organization.Getter,
|
||||
authz authz.AuthZ,
|
||||
config user.RootConfig,
|
||||
) user.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
|
||||
store: store,
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
config: config,
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Start(ctx context.Context) error {
|
||||
if !s.config.Enabled {
|
||||
<-s.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := s.reconcile(ctx)
|
||||
if err == nil {
|
||||
s.settings.Logger().InfoContext(ctx, "root user reconciliation completed successfully")
|
||||
<-s.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
s.settings.Logger().WarnContext(ctx, "root user reconciliation failed, retrying", "error", err)
|
||||
|
||||
select {
|
||||
case <-s.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Stop(ctx context.Context) error {
|
||||
close(s.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) reconcile(ctx context.Context) error {
|
||||
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
|
||||
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return s.reconcileRootUser(ctx, org.ID)
|
||||
}
|
||||
|
||||
func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) error {
|
||||
existingRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingRoot == nil {
|
||||
return s.createOrPromoteRootUser(ctx, orgID)
|
||||
}
|
||||
|
||||
return s.updateExistingRootUser(ctx, orgID, existingRoot)
|
||||
}
|
||||
|
||||
func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID) error {
|
||||
existingUser, err := s.store.GetUserByEmailAndOrgID(ctx, s.config.Email, orgID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingUser != nil {
|
||||
oldRole := existingUser.Role
|
||||
|
||||
existingUser.PromoteToRoot()
|
||||
if err := s.module.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldRole != types.RoleAdmin {
|
||||
if err := s.authz.ModifyGrant(ctx,
|
||||
orgID,
|
||||
roletypes.MustGetSigNozManagedRoleFromExistingRole(oldRole),
|
||||
roletypes.MustGetSigNozManagedRoleFromExistingRole(types.RoleAdmin),
|
||||
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.setPassword(ctx, existingUser.ID)
|
||||
}
|
||||
|
||||
// Create new root user
|
||||
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
factorPassword, err := types.NewFactorPassword(s.config.Password, newUser.ID.StringValue())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.module.CreateUser(ctx, newUser, user.WithFactorPassword(factorPassword))
|
||||
}
|
||||
|
||||
func (s *service) updateExistingRootUser(ctx context.Context, orgID valuer.UUID, existingRoot *types.User) error {
|
||||
existingRoot.PromoteToRoot()
|
||||
|
||||
if existingRoot.Email != s.config.Email {
|
||||
existingRoot.UpdateEmail(s.config.Email)
|
||||
if err := s.module.UpdateAnyUser(ctx, orgID, existingRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.setPassword(ctx, existingRoot.ID)
|
||||
}
|
||||
|
||||
func (s *service) setPassword(ctx context.Context, userID valuer.UUID) error {
|
||||
password, err := s.store.GetPasswordByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
factorPassword, err := types.NewFactorPassword(s.config.Password, userID.StringValue())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.CreatePassword(ctx, factorPassword)
|
||||
}
|
||||
|
||||
if !password.Equals(s.config.Password) {
|
||||
if err := password.Update(s.config.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.UpdatePassword(ctx, password)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -210,20 +210,24 @@ func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role,
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User) (*types.User, error) {
|
||||
user.UpdatedAt = time.Now()
|
||||
_, err := store.sqlstore.BunDB().NewUpdate().
|
||||
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(user).
|
||||
Column("display_name").
|
||||
Column("email").
|
||||
Column("role").
|
||||
Column("is_root").
|
||||
Column("updated_at").
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", user.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist in org: %s", id, orgID)
|
||||
return store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user does not exist in org: %s", orgID)
|
||||
}
|
||||
return user, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.GettableUser, error) {
|
||||
@@ -602,6 +606,22 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
|
||||
})
|
||||
}
|
||||
|
||||
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
|
||||
user := new(types.User)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(user).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("is_root = ?", true).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "root user for org %s not found", orgID)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
|
||||
users := []*types.User{}
|
||||
err := store.
|
||||
|
||||
7
pkg/modules/user/service.go
Normal file
7
pkg/modules/user/service.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package user
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/factory"
|
||||
|
||||
type Service interface {
|
||||
factory.Service
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type Module interface {
|
||||
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
|
||||
|
||||
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User, updatedBy string) (*types.User, error)
|
||||
|
||||
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
|
||||
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error
|
||||
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
|
||||
|
||||
// invite
|
||||
@@ -54,6 +57,9 @@ type Module interface {
|
||||
}
|
||||
|
||||
type Getter interface {
|
||||
// Get root user by org id.
|
||||
GetRootUserByOrgID(context.Context, valuer.UUID) (*types.User, error)
|
||||
|
||||
// Get gets the users based on the given id
|
||||
ListByOrgID(context.Context, valuer.UUID) ([]*types.User, error)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
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{}
|
||||
|
||||
if def.Strategy.AWSMetrics != nil && serviceConfig.Metrics.Enabled {
|
||||
wg.Add(1)
|
||||
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 {
|
||||
wg.Add(1)
|
||||
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 _, metric := range def.IngestionStatusCheck.Metrics {
|
||||
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
|
||||
CategoryID: metric.Category,
|
||||
CategoryDisplayName: metric.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
for index, 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[index] = &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 _, log := range def.IngestionStatusCheck.Logs {
|
||||
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
|
||||
CategoryID: log.Category,
|
||||
CategoryDisplayName: log.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
for index, 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[index] = &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 = append(
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
package implazureprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/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 (
|
||||
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
|
||||
}
|
||||
|
||||
connectionStatus, err := a.getServiceConnectionStatus(
|
||||
ctx,
|
||||
*req.CloudAccountID,
|
||||
req.OrgID,
|
||||
&details.AzureServiceDefinition,
|
||||
config,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
details.ConnectionStatus = connectionStatus
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (a *azureProvider) getServiceConnectionStatus(
|
||||
ctx context.Context,
|
||||
cloudAccountID string,
|
||||
orgID string,
|
||||
def *integrationstypes.AzureServiceDefinition,
|
||||
serviceConfig *integrationstypes.AzureCloudServiceConfig,
|
||||
) (*integrationstypes.ServiceConnectionStatus, error) {
|
||||
if def.Strategy == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resp := new(integrationstypes.ServiceConnectionStatus)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
if def.Strategy.AzureMetrics != nil {
|
||||
wg.Add(1)
|
||||
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()
|
||||
|
||||
enabled := false
|
||||
|
||||
for _, cfg := range serviceConfig.Metrics {
|
||||
if cfg.Enabled {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := a.getServiceMetricsConnectionStatus(ctx, cloudAccountID, orgID, def)
|
||||
resp.Metrics = status
|
||||
}()
|
||||
}
|
||||
|
||||
if def.Strategy.AzureLogs != nil {
|
||||
wg.Add(1)
|
||||
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()
|
||||
|
||||
enabled := false
|
||||
|
||||
for _, cfg := range serviceConfig.Logs {
|
||||
if cfg.Enabled {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := a.getServiceLogsConnectionStatus(ctx, cloudAccountID, orgID, def)
|
||||
resp.Logs = status
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *azureProvider) getServiceMetricsConnectionStatus(
|
||||
ctx context.Context,
|
||||
cloudAccountID string,
|
||||
orgID string,
|
||||
def *integrationstypes.AzureServiceDefinition,
|
||||
) ([]*integrationstypes.SignalConnectionStatus, error) {
|
||||
if def.Strategy == nil ||
|
||||
len(def.Strategy.AzureMetrics) < 1 ||
|
||||
len(def.DataCollected.Metrics) < 1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusResp := make([]*integrationstypes.SignalConnectionStatus, 0)
|
||||
|
||||
for _, metric := range def.IngestionStatusCheck.Metrics {
|
||||
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
|
||||
CategoryID: metric.Category,
|
||||
CategoryDisplayName: metric.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
for index, 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[index] = &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 *azureProvider) getServiceLogsConnectionStatus(
|
||||
ctx context.Context,
|
||||
cloudAccountID string,
|
||||
orgID string,
|
||||
def *integrationstypes.AzureServiceDefinition,
|
||||
) ([]*integrationstypes.SignalConnectionStatus, error) {
|
||||
if def.Strategy == nil ||
|
||||
len(def.Strategy.AzureLogs) < 1 ||
|
||||
len(def.DataCollected.Logs) < 1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusResp := make([]*integrationstypes.SignalConnectionStatus, 0)
|
||||
|
||||
for _, log := range def.IngestionStatusCheck.Logs {
|
||||
statusResp = append(statusResp, &integrationstypes.SignalConnectionStatus{
|
||||
CategoryID: log.Category,
|
||||
CategoryDisplayName: log.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
for index, 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[index] = &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 *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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
26
pkg/query-service/app/cloudintegrations/providerregistry.go
Normal file
26
pkg/query-service/app/cloudintegrations/providerregistry.go
Normal 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
|
||||
}
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"id": "blobstorage",
|
||||
"title": "Blob Storage",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supported_signals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"ingestion_status_check": {
|
||||
"metrics": [
|
||||
{
|
||||
"category": "$default",
|
||||
"display_name": "Default",
|
||||
"checks": [
|
||||
{
|
||||
"key": "placeholder",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "transactions",
|
||||
"display_name": "Transactions",
|
||||
"checks": [
|
||||
{
|
||||
"key": "placeholder",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"category": "$default",
|
||||
"display_name": "Default",
|
||||
"checks": [
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"name": "placeholder",
|
||||
"operator": "ILIKE",
|
||||
"value": "%/ecs/%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"id": "frontdoor",
|
||||
"title": "Front Door",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supported_signals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"ingestion_status_check": {
|
||||
"metrics": [
|
||||
{
|
||||
"category": "overview",
|
||||
"display_name": "Overview",
|
||||
"checks": [
|
||||
{
|
||||
"key": "placeholder",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "insights",
|
||||
"display_name": "Blob Storage Insights",
|
||||
"checks": [
|
||||
{
|
||||
"key": "placeholder",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"category": "$default",
|
||||
"display_name": "Default",
|
||||
"checks": [
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"name": "placeholder",
|
||||
"operator": "ILIKE",
|
||||
"value": "%/ecs/%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -183,7 +182,7 @@ type APIHandlerOpts struct {
|
||||
}
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
|
||||
func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, error) {
|
||||
querierOpts := querier.QuerierOptions{
|
||||
Reader: opts.Reader,
|
||||
Cache: opts.Signoz.Cache,
|
||||
@@ -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,
|
||||
@@ -270,6 +269,11 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the root user is enabled, the setup is complete
|
||||
if config.User.Root.Enabled {
|
||||
aH.SetupCompleted = true
|
||||
}
|
||||
|
||||
aH.Upgrader = &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
@@ -1217,13 +1221,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 +1300,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)
|
||||
@@ -2045,7 +2060,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
organization := types.NewOrganization(req.OrgDisplayName)
|
||||
organization := types.NewOrganization(req.OrgDisplayName, req.OrgName)
|
||||
user, errv2 := aH.Signoz.Modules.User.CreateFirstUser(r.Context(), organization, req.Name, req.Email, req.Password)
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
@@ -3267,15 +3282,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 +3298,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 +3500,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 +3541,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 +3742,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 +3769,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 +3786,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user