diff --git a/Makefile b/Makefile index cc8bf1cf0ee..2b2e47c9fa8 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ BUILD_HASH ?= $(shell git rev-parse --short HEAD) BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") BUILD_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) DEV_LICENSE_SIGNOZ_IO ?= https://staging-license.signoz.io/api/v1 +ZEUS_URL ?= https://api.signoz.cloud DEV_BUILD ?= "" # set to any non-empty value to enable dev build # Internal variables or constants. @@ -33,8 +34,9 @@ buildHash=${PACKAGE}/pkg/query-service/version.buildHash buildTime=${PACKAGE}/pkg/query-service/version.buildTime gitBranch=${PACKAGE}/pkg/query-service/version.gitBranch licenseSignozIo=${PACKAGE}/ee/query-service/constants.LicenseSignozIo +zeusURL=${PACKAGE}/ee/query-service/constants.ZeusURL -LD_FLAGS=-X ${buildHash}=${BUILD_HASH} -X ${buildTime}=${BUILD_TIME} -X ${buildVersion}=${BUILD_VERSION} -X ${gitBranch}=${BUILD_BRANCH} +LD_FLAGS=-X ${buildHash}=${BUILD_HASH} -X ${buildTime}=${BUILD_TIME} -X ${buildVersion}=${BUILD_VERSION} -X ${gitBranch}=${BUILD_BRANCH} -X ${zeusURL}=${ZEUS_URL} DEV_LD_FLAGS=-X ${licenseSignozIo}=${DEV_LICENSE_SIGNOZ_IO} all: build-push-frontend build-push-query-service diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index 9966168ea1b..3887e223f78 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -146,11 +146,12 @@ services: condition: on-failure query-service: - image: signoz/query-service:0.56.0 + image: signoz/query-service:0.61.0 command: [ "-config=/root/config/prometheus.yml", - "--use-logs-new-schema=true" + "--use-logs-new-schema=true", + "--use-trace-new-schema=true" ] # ports: # - "6060:6060" # pprof port @@ -186,7 +187,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:0.56.0 + image: signoz/frontend:0.61.0 deploy: restart_policy: condition: on-failure @@ -199,7 +200,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector: - image: signoz/signoz-otel-collector:0.111.5 + image: signoz/signoz-otel-collector:0.111.14 command: [ "--config=/etc/otel-collector-config.yaml", @@ -237,13 +238,15 @@ services: - query-service otel-collector-migrator: - image: signoz/signoz-schema-migrator:0.111.5 + image: signoz/signoz-schema-migrator:0.111.14 deploy: restart_policy: condition: on-failure delay: 5s command: - - "--dsn=tcp://clickhouse:9000" + - "sync" + - "--dsn=tcp://clickhouse:9000" + - "--up=" depends_on: - clickhouse # - clickhouse-2 diff --git a/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml b/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml index 8c0b30df619..1b81ea214a7 100644 --- a/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml +++ b/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml @@ -110,6 +110,7 @@ exporters: clickhousetraces: datasource: tcp://clickhouse:9000/signoz_traces low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} + use_new_schema: true clickhousemetricswrite: endpoint: tcp://clickhouse:9000/signoz_metrics resource_to_telemetry_conversion: diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index ec9e0697fb9..5bade6b2da2 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -69,10 +69,12 @@ services: - --storage.path=/data otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.14} container_name: otel-migrator command: + - "sync" - "--dsn=tcp://clickhouse:9000" + - "--up=" depends_on: clickhouse: condition: service_healthy @@ -84,7 +86,7 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` otel-collector: container_name: signoz-otel-collector - image: signoz/signoz-otel-collector:0.111.5 + image: signoz/signoz-otel-collector:0.111.14 command: [ "--config=/etc/otel-collector-config.yaml", diff --git a/deploy/docker/clickhouse-setup/docker-compose-local.yaml b/deploy/docker/clickhouse-setup/docker-compose-local.yaml index 7effc129fe0..7a4222ff8cc 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-local.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-local.yaml @@ -25,7 +25,8 @@ services: command: [ "-config=/root/config/prometheus.yml", - "--use-logs-new-schema=true" + "--use-logs-new-schema=true", + "--use-trace-new-schema=true" ] ports: - "6060:6060" diff --git a/deploy/docker/clickhouse-setup/docker-compose-minimal.yaml b/deploy/docker/clickhouse-setup/docker-compose-minimal.yaml index f087028f1f6..37df9590d30 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-minimal.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-minimal.yaml @@ -162,12 +162,13 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.56.0} + image: signoz/query-service:${DOCKER_TAG:-0.61.0} container_name: signoz-query-service command: [ "-config=/root/config/prometheus.yml", - "--use-logs-new-schema=true" + "--use-logs-new-schema=true", + "--use-trace-new-schema=true" ] # ports: # - "6060:6060" # pprof port @@ -201,7 +202,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.56.0} + image: signoz/frontend:${DOCKER_TAG:-0.61.0} container_name: signoz-frontend restart: on-failure depends_on: @@ -213,7 +214,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator-sync: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.14} container_name: otel-migrator-sync command: - "sync" @@ -228,7 +229,7 @@ services: # condition: service_healthy otel-collector-migrator-async: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.14} container_name: otel-migrator-async command: - "async" @@ -245,7 +246,7 @@ services: # condition: service_healthy otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.111.14} container_name: signoz-otel-collector command: [ diff --git a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml index 72d573ad03b..bd00cf17021 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml @@ -167,13 +167,14 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.56.0} + image: signoz/query-service:${DOCKER_TAG:-0.61.0} container_name: signoz-query-service command: [ "-config=/root/config/prometheus.yml", "-gateway-url=https://api.staging.signoz.cloud", - "--use-logs-new-schema=true" + "--use-logs-new-schema=true", + "--use-trace-new-schema=true" ] # ports: # - "6060:6060" # pprof port @@ -208,7 +209,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.56.0} + image: signoz/frontend:${DOCKER_TAG:-0.61.0} container_name: signoz-frontend restart: on-failure depends_on: @@ -220,7 +221,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.111.14} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -234,7 +235,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.111.5} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.111.14} container_name: signoz-otel-collector command: [ diff --git a/deploy/docker/clickhouse-setup/otel-collector-config.yaml b/deploy/docker/clickhouse-setup/otel-collector-config.yaml index cba7756d8e5..b73acdea115 100644 --- a/deploy/docker/clickhouse-setup/otel-collector-config.yaml +++ b/deploy/docker/clickhouse-setup/otel-collector-config.yaml @@ -119,6 +119,7 @@ exporters: clickhousetraces: datasource: tcp://clickhouse:9000/signoz_traces low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} + use_new_schema: true clickhousemetricswrite: endpoint: tcp://clickhouse:9000/signoz_metrics resource_to_telemetry_conversion: diff --git a/ee/query-service/Dockerfile b/ee/query-service/Dockerfile index 55ed33aa60a..5c8f2f6f35d 100644 --- a/ee/query-service/Dockerfile +++ b/ee/query-service/Dockerfile @@ -1,5 +1,5 @@ # use a minimal alpine image -FROM alpine:3.18.6 +FROM alpine:3.20.3 # Add Maintainer Info LABEL maintainer="signoz" diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 4291b3f488e..181186d3230 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -38,9 +38,9 @@ type APIHandlerOptions struct { Cache cache.Cache Gateway *httputil.ReverseProxy // Querier Influx Interval - FluxInterval time.Duration - UseLogsNewSchema bool - UseLicensesV3 bool + FluxInterval time.Duration + UseLogsNewSchema bool + UseTraceNewSchema bool } type APIHandler struct { @@ -66,7 +66,7 @@ func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) { Cache: opts.Cache, FluxInterval: opts.FluxInterval, UseLogsNewSchema: opts.UseLogsNewSchema, - UseLicensesV3: opts.UseLicensesV3, + UseTraceNewSchema: opts.UseTraceNewSchema, }) if err != nil { @@ -181,23 +181,16 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew Methods(http.MethodGet) // v3 - router.HandleFunc("/api/v3/licenses", - am.ViewAccess(ah.listLicensesV3)). - Methods(http.MethodGet) - - router.HandleFunc("/api/v3/licenses", - am.AdminAccess(ah.applyLicenseV3)). - Methods(http.MethodPost) - - router.HandleFunc("/api/v3/licenses", - am.AdminAccess(ah.refreshLicensesV3)). - Methods(http.MethodPut) + router.HandleFunc("/api/v3/licenses", am.ViewAccess(ah.listLicensesV3)).Methods(http.MethodGet) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.applyLicenseV3)).Methods(http.MethodPost) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.refreshLicensesV3)).Methods(http.MethodPut) + router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.getActiveLicenseV3)).Methods(http.MethodGet) // v4 router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) // Gateway - router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.AdminAccess(ah.ServeGatewayHTTP)) + router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.EditAccess(ah.ServeGatewayHTTP)) ah.APIHandler.RegisterRoutes(router, am) diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index 0cb7fa2babb..7a098d4e636 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -95,7 +95,7 @@ func (ah *APIHandler) applyLicense(w http.ResponseWriter, r *http.Request) { RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil) return } - license, apiError := ah.LM().Activate(r.Context(), l.Key) + license, apiError := ah.LM().ActivateV3(r.Context(), l.Key) if apiError != nil { RespondError(w, apiError, nil) return @@ -115,6 +115,23 @@ func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) { ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses)) } +func (ah *APIHandler) getActiveLicenseV3(w http.ResponseWriter, r *http.Request) { + activeLicense, err := ah.LM().GetRepo().GetActiveLicenseV3(r.Context()) + if err != nil { + RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) + return + } + // return 404 not found if there is no active license + if activeLicense == nil { + RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no active license found")}, nil) + return + } + + // TODO deprecate this when we move away from key for stripe + activeLicense.Data["key"] = activeLicense.Key + render.Success(w, http.StatusOK, activeLicense.Data) +} + // this function is called by zeus when inserting licenses in the query-service func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) { var licenseKey ApplyLicenseRequest @@ -218,6 +235,10 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) { func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License { licensesV2 := []model.License{} for _, l := range licenses { + planKeyFromPlanName, ok := model.MapOldPlanKeyToNewPlanName[l.PlanName] + if !ok { + planKeyFromPlanName = model.Basic + } licenseV2 := model.License{ Key: l.Key, ActivationId: "", @@ -226,7 +247,7 @@ func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License { ValidationMessage: "", IsCurrent: l.IsCurrent, LicensePlan: model.LicensePlan{ - PlanKey: l.PlanName, + PlanKey: planKeyFromPlanName, ValidFrom: l.ValidFrom, ValidUntil: l.ValidUntil, Status: l.Status}, @@ -237,24 +258,12 @@ func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License { } func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) { - - var licenses []model.License - - if ah.UseLicensesV3 { - licensesV3, err := ah.LM().GetLicensesV3(r.Context()) - if err != nil { - RespondError(w, err, nil) - return - } - licenses = convertLicenseV3ToLicenseV2(licensesV3) - } else { - _licenses, apiError := ah.LM().GetLicenses(r.Context()) - if apiError != nil { - RespondError(w, apiError, nil) - return - } - licenses = _licenses + licensesV3, apierr := ah.LM().GetLicensesV3(r.Context()) + if apierr != nil { + RespondError(w, apierr, nil) + return } + licenses := convertLicenseV3ToLicenseV2(licensesV3) resp := model.Licenses{ TrialStart: -1, diff --git a/ee/query-service/app/db/reader.go b/ee/query-service/app/db/reader.go index fcab1cb991c..9794abd0137 100644 --- a/ee/query-service/app/db/reader.go +++ b/ee/query-service/app/db/reader.go @@ -26,8 +26,9 @@ func NewDataConnector( dialTimeout time.Duration, cluster string, useLogsNewSchema bool, + useTraceNewSchema bool, ) *ClickhouseReader { - ch := basechr.NewReader(localDB, promConfigPath, lm, maxIdleConns, maxOpenConns, dialTimeout, cluster, useLogsNewSchema) + ch := basechr.NewReader(localDB, promConfigPath, lm, maxIdleConns, maxOpenConns, dialTimeout, cluster, useLogsNewSchema, useTraceNewSchema) return &ClickhouseReader{ conn: ch.GetConn(), appdb: localDB, diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index a8acbc46e93..938b72b5a35 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -77,7 +77,7 @@ type ServerOptions struct { Cluster string GatewayUrl string UseLogsNewSchema bool - UseLicensesV3 bool + UseTraceNewSchema bool } // Server runs HTTP api service @@ -134,7 +134,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // initiate license manager - lm, err := licensepkg.StartManager("sqlite", localDB, serverOptions.UseLicensesV3) + lm, err := licensepkg.StartManager("sqlite", localDB) if err != nil { return nil, err } @@ -156,6 +156,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { serverOptions.DialTimeout, serverOptions.Cluster, serverOptions.UseLogsNewSchema, + serverOptions.UseTraceNewSchema, ) go qb.Start(readerReady) reader = qb @@ -189,6 +190,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { serverOptions.DisableRules, lm, serverOptions.UseLogsNewSchema, + serverOptions.UseTraceNewSchema, ) if err != nil { @@ -270,7 +272,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { FluxInterval: fluxInterval, Gateway: gatewayProxy, UseLogsNewSchema: serverOptions.UseLogsNewSchema, - UseLicensesV3: serverOptions.UseLicensesV3, + UseTraceNewSchema: serverOptions.UseTraceNewSchema, } apiHandler, err := api.NewAPIHandler(apiOpts) @@ -313,10 +315,10 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, r := baseapp.NewRouter() - r.Use(baseapp.LogCommentEnricher) r.Use(setTimeoutMiddleware) r.Use(s.analyticsMiddleware) r.Use(loggingMiddlewarePrivate) + r.Use(baseapp.LogCommentEnricher) apiHandler.RegisterPrivateRoutes(r) @@ -356,10 +358,10 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e } am := baseapp.NewAuthMiddleware(getUserFromRequest) - r.Use(baseapp.LogCommentEnricher) r.Use(setTimeoutMiddleware) r.Use(s.analyticsMiddleware) r.Use(loggingMiddleware) + r.Use(baseapp.LogCommentEnricher) apiHandler.RegisterRoutes(r, am) apiHandler.RegisterLogsRoutes(r, am) @@ -737,7 +739,8 @@ func makeRulesManager( cache cache.Cache, disableRules bool, fm baseint.FeatureLookup, - useLogsNewSchema bool) (*baserules.Manager, error) { + useLogsNewSchema bool, + useTraceNewSchema bool) (*baserules.Manager, error) { // create engine pqle, err := pqle.FromConfigPath(promConfigPath) @@ -767,8 +770,9 @@ func makeRulesManager( EvalDelay: baseconst.GetEvalDelay(), PrepareTaskFunc: rules.PrepareTaskFunc, - PrepareTestRuleFunc: rules.TestNotification, UseLogsNewSchema: useLogsNewSchema, + UseTraceNewSchema: useTraceNewSchema, + PrepareTestRuleFunc: rules.TestNotification, } // create Manager diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index 0931fd01fcc..57f56929715 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -13,7 +13,9 @@ var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") -var ZeusURL = GetOrDefaultEnv("ZEUS_URL", "ZeusURL") + +// this is set via build time variable +var ZeusURL = "https://api.signoz.cloud" func GetOrDefaultEnv(key string, fallback string) string { v := os.Getenv(key) diff --git a/ee/query-service/integrations/signozio/response.go b/ee/query-service/integrations/signozio/response.go index f0b0132d1bd..891ea77da10 100644 --- a/ee/query-service/integrations/signozio/response.go +++ b/ee/query-service/integrations/signozio/response.go @@ -2,18 +2,6 @@ package signozio type status string -type ActivationResult struct { - Status status `json:"status"` - Data *ActivationResponse `json:"data,omitempty"` - ErrorType string `json:"errorType,omitempty"` - Error string `json:"error,omitempty"` -} - -type ActivationResponse struct { - ActivationId string `json:"ActivationId"` - PlanDetails string `json:"PlanDetails"` -} - type ValidateLicenseResponse struct { Status status `json:"status"` Data map[string]interface{} `json:"data"` diff --git a/ee/query-service/integrations/signozio/signozio.go b/ee/query-service/integrations/signozio/signozio.go index 6c0b937c80d..a3a5cad414b 100644 --- a/ee/query-service/integrations/signozio/signozio.go +++ b/ee/query-service/integrations/signozio/signozio.go @@ -10,7 +10,6 @@ import ( "time" "github.com/pkg/errors" - "go.uber.org/zap" "go.signoz.io/signoz/ee/query-service/constants" "go.signoz.io/signoz/ee/query-service/model" @@ -39,86 +38,6 @@ func init() { C = New() } -// ActivateLicense sends key to license.signoz.io and gets activation data -func ActivateLicense(key, siteId string) (*ActivationResponse, *model.ApiError) { - licenseReq := map[string]string{ - "key": key, - "siteId": siteId, - } - - reqString, _ := json.Marshal(licenseReq) - httpResponse, err := http.Post(C.Prefix+"/licenses/activate", APPLICATION_JSON, bytes.NewBuffer(reqString)) - - if err != nil { - zap.L().Error("failed to connect to license.signoz.io", zap.Error(err)) - return nil, model.BadRequest(fmt.Errorf("unable to connect with license.signoz.io, please check your network connection")) - } - - httpBody, err := io.ReadAll(httpResponse.Body) - if err != nil { - zap.L().Error("failed to read activation response from license.signoz.io", zap.Error(err)) - return nil, model.BadRequest(fmt.Errorf("failed to read activation response from license.signoz.io")) - } - - defer httpResponse.Body.Close() - - // read api request result - result := ActivationResult{} - err = json.Unmarshal(httpBody, &result) - if err != nil { - zap.L().Error("failed to marshal activation response from license.signoz.io", zap.Error(err)) - return nil, model.InternalError(errors.Wrap(err, "failed to marshal license activation response")) - } - - switch httpResponse.StatusCode { - case 200, 201: - return result.Data, nil - case 400, 401: - return nil, model.BadRequest(fmt.Errorf(fmt.Sprintf("failed to activate: %s", result.Error))) - default: - return nil, model.InternalError(fmt.Errorf(fmt.Sprintf("failed to activate: %s", result.Error))) - } - -} - -// ValidateLicense validates the license key -func ValidateLicense(activationId string) (*ActivationResponse, *model.ApiError) { - validReq := map[string]string{ - "activationId": activationId, - } - - reqString, _ := json.Marshal(validReq) - response, err := http.Post(C.Prefix+"/licenses/validate", APPLICATION_JSON, bytes.NewBuffer(reqString)) - - if err != nil { - return nil, model.BadRequest(errors.Wrap(err, "unable to connect with license.signoz.io, please check your network connection")) - } - - body, err := io.ReadAll(response.Body) - if err != nil { - return nil, model.BadRequest(errors.Wrap(err, "failed to read validation response from license.signoz.io")) - } - - defer response.Body.Close() - - switch response.StatusCode { - case 200, 201: - a := ActivationResult{} - err = json.Unmarshal(body, &a) - if err != nil { - return nil, model.BadRequest(errors.Wrap(err, "failed to marshal license validation response")) - } - return a.Data, nil - case 400, 401: - return nil, model.BadRequest(errors.Wrap(fmt.Errorf(string(body)), - "bad request error received from license.signoz.io")) - default: - return nil, model.InternalError(errors.Wrap(fmt.Errorf(string(body)), - "internal error received from license.signoz.io")) - } - -} - func ValidateLicenseV3(licenseKey string) (*model.LicenseV3, *model.ApiError) { // Creating an HTTP client with a timeout for better control diff --git a/ee/query-service/license/db.go b/ee/query-service/license/db.go index eae48e266df..1dba4053d73 100644 --- a/ee/query-service/license/db.go +++ b/ee/query-service/license/db.go @@ -78,9 +78,7 @@ func (r *Repo) GetLicensesV3(ctx context.Context) ([]*model.LicenseV3, error) { return licenseV3Data, nil } -// GetActiveLicense fetches the latest active license from DB. -// If the license is not present, expect a nil license and a nil error in the output. -func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) { +func (r *Repo) GetActiveLicenseV2(ctx context.Context) (*model.License, *basemodel.ApiError) { var err error licenses := []model.License{} @@ -109,6 +107,21 @@ func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel return active, nil } +// GetActiveLicense fetches the latest active license from DB. +// If the license is not present, expect a nil license and a nil error in the output. +func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) { + activeLicenseV3, err := r.GetActiveLicenseV3(ctx) + if err != nil { + return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err)) + } + + if activeLicenseV3 == nil { + return nil, nil + } + activeLicenseV2 := model.ConvertLicenseV3ToLicenseV2(activeLicenseV3) + return activeLicenseV2, nil +} + func (r *Repo) GetActiveLicenseV3(ctx context.Context) (*model.LicenseV3, error) { var err error licenses := []model.LicenseDB{} diff --git a/ee/query-service/license/manager.go b/ee/query-service/license/manager.go index 6dcc704e3ad..c036a01ab58 100644 --- a/ee/query-service/license/manager.go +++ b/ee/query-service/license/manager.go @@ -51,7 +51,7 @@ type Manager struct { activeFeatures basemodel.FeatureSet } -func StartManager(dbType string, db *sqlx.DB, useLicensesV3 bool, features ...basemodel.Feature) (*Manager, error) { +func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*Manager, error) { if LM != nil { return LM, nil } @@ -67,7 +67,7 @@ func StartManager(dbType string, db *sqlx.DB, useLicensesV3 bool, features ...ba repo: &repo, } - if err := m.start(useLicensesV3, features...); err != nil { + if err := m.start(features...); err != nil { return m, err } LM = m @@ -75,16 +75,8 @@ func StartManager(dbType string, db *sqlx.DB, useLicensesV3 bool, features ...ba } // start loads active license in memory and initiates validator -func (lm *Manager) start(useLicensesV3 bool, features ...basemodel.Feature) error { - - var err error - if useLicensesV3 { - err = lm.LoadActiveLicenseV3(features...) - } else { - err = lm.LoadActiveLicense(features...) - } - - return err +func (lm *Manager) start(features ...basemodel.Feature) error { + return lm.LoadActiveLicenseV3(features...) } func (lm *Manager) Stop() { @@ -92,31 +84,6 @@ func (lm *Manager) Stop() { <-lm.terminated } -func (lm *Manager) SetActive(l *model.License, features ...basemodel.Feature) { - lm.mutex.Lock() - defer lm.mutex.Unlock() - - if l == nil { - return - } - - lm.activeLicense = l - lm.activeFeatures = append(l.FeatureSet, features...) - // set default features - setDefaultFeatures(lm) - - err := lm.InitFeatures(lm.activeFeatures) - if err != nil { - zap.L().Panic("Couldn't activate features", zap.Error(err)) - } - if !lm.validatorRunning { - // we want to make sure only one validator runs, - // we already have lock() so good to go - lm.validatorRunning = true - go lm.Validator(context.Background()) - } - -} func (lm *Manager) SetActiveV3(l *model.LicenseV3, features ...basemodel.Feature) { lm.mutex.Lock() defer lm.mutex.Unlock() @@ -147,29 +114,6 @@ func setDefaultFeatures(lm *Manager) { lm.activeFeatures = append(lm.activeFeatures, baseconstants.DEFAULT_FEATURE_SET...) } -// LoadActiveLicense loads the most recent active license -func (lm *Manager) LoadActiveLicense(features ...basemodel.Feature) error { - active, err := lm.repo.GetActiveLicense(context.Background()) - if err != nil { - return err - } - if active != nil { - lm.SetActive(active, features...) - } else { - zap.L().Info("No active license found, defaulting to basic plan") - // if no active license is found, we default to basic(free) plan with all default features - lm.activeFeatures = model.BasicPlan - setDefaultFeatures(lm) - err := lm.InitFeatures(lm.activeFeatures) - if err != nil { - zap.L().Error("Couldn't initialize features", zap.Error(err)) - return err - } - } - - return nil -} - func (lm *Manager) LoadActiveLicenseV3(features ...basemodel.Feature) error { active, err := lm.repo.GetActiveLicenseV3(context.Background()) if err != nil { @@ -229,38 +173,20 @@ func (lm *Manager) GetLicensesV3(ctx context.Context) (response []*model.License if lm.activeLicenseV3 != nil && l.Key == lm.activeLicenseV3.Key { l.IsCurrent = true } + if l.ValidUntil == -1 { + // for subscriptions, there is no end-date as such + // but for showing user some validity we default one year timespan + l.ValidUntil = l.ValidFrom + 31556926 + } response = append(response, l) } return response, nil } -// Validator validates license after an epoch of time -func (lm *Manager) Validator(ctx context.Context) { - defer close(lm.terminated) - tick := time.NewTicker(validationFrequency) - defer tick.Stop() - - lm.Validate(ctx) - - for { - select { - case <-lm.done: - return - default: - select { - case <-lm.done: - return - case <-tick.C: - lm.Validate(ctx) - } - } - - } -} - // Validator validates license after an epoch of time func (lm *Manager) ValidatorV3(ctx context.Context) { + zap.L().Info("ValidatorV3 started!") defer close(lm.terminated) tick := time.NewTicker(validationFrequency) defer tick.Stop() @@ -283,74 +209,6 @@ func (lm *Manager) ValidatorV3(ctx context.Context) { } } -// Validate validates the current active license -func (lm *Manager) Validate(ctx context.Context) (reterr error) { - zap.L().Info("License validation started") - if lm.activeLicense == nil { - return nil - } - - defer func() { - lm.mutex.Lock() - - lm.lastValidated = time.Now().Unix() - if reterr != nil { - zap.L().Error("License validation completed with error", zap.Error(reterr)) - atomic.AddUint64(&lm.failedAttempts, 1) - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_CHECK_FAILED, - map[string]interface{}{"err": reterr.Error()}, "", true, false) - } else { - zap.L().Info("License validation completed with no errors") - } - - lm.mutex.Unlock() - }() - - response, apiError := validate.ValidateLicense(lm.activeLicense.ActivationId) - if apiError != nil { - zap.L().Error("failed to validate license", zap.Error(apiError.Err)) - return apiError.Err - } - - if response.PlanDetails == lm.activeLicense.PlanDetails { - // license plan hasnt changed, nothing to do - return nil - } - - if response.PlanDetails != "" { - - // copy and replace the active license record - l := model.License{ - Key: lm.activeLicense.Key, - CreatedAt: lm.activeLicense.CreatedAt, - PlanDetails: response.PlanDetails, - ValidationMessage: lm.activeLicense.ValidationMessage, - ActivationId: lm.activeLicense.ActivationId, - } - - if err := l.ParsePlan(); err != nil { - zap.L().Error("failed to parse updated license", zap.Error(err)) - return err - } - - // updated plan is parsable, check if plan has changed - if lm.activeLicense.PlanDetails != response.PlanDetails { - err := lm.repo.UpdatePlanDetails(ctx, lm.activeLicense.Key, response.PlanDetails) - if err != nil { - // unexpected db write issue but we can let the user continue - // and wait for update to work in next cycle. - zap.L().Error("failed to validate license", zap.Error(err)) - } - } - - // activate the update license plan - lm.SetActive(&l) - } - - return nil -} - -// todo[vikrantgupta25]: check the comparison here between old and new license! func (lm *Manager) RefreshLicense(ctx context.Context) *model.ApiError { license, apiError := validate.ValidateLicenseV3(lm.activeLicenseV3.Key) @@ -398,50 +256,6 @@ func (lm *Manager) ValidateV3(ctx context.Context) (reterr error) { return nil } -// Activate activates a license key with signoz server -func (lm *Manager) Activate(ctx context.Context, key string) (licenseResponse *model.License, errResponse *model.ApiError) { - defer func() { - if errResponse != nil { - userEmail, err := auth.GetEmailFromJwt(ctx) - if err == nil { - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_ACT_FAILED, - map[string]interface{}{"err": errResponse.Err.Error()}, userEmail, true, false) - } - } - }() - - response, apiError := validate.ActivateLicense(key, "") - if apiError != nil { - zap.L().Error("failed to activate license", zap.Error(apiError.Err)) - return nil, apiError - } - - l := &model.License{ - Key: key, - ActivationId: response.ActivationId, - PlanDetails: response.PlanDetails, - } - - // parse validity and features from the plan details - err := l.ParsePlan() - - if err != nil { - zap.L().Error("failed to activate license", zap.Error(err)) - return nil, model.InternalError(err) - } - - // store the license before activating it - err = lm.repo.InsertLicense(ctx, l) - if err != nil { - zap.L().Error("failed to activate license", zap.Error(err)) - return nil, model.InternalError(err) - } - - // license is valid, activate it - lm.SetActive(l) - return l, nil -} - func (lm *Manager) ActivateV3(ctx context.Context, licenseKey string) (licenseResponse *model.LicenseV3, errResponse *model.ApiError) { defer func() { if errResponse != nil { diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 55e70893e6f..a93a034f87a 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -94,7 +94,7 @@ func main() { var cluster string var useLogsNewSchema bool - var useLicensesV3 bool + var useTraceNewSchema bool var cacheConfigPath, fluxInterval string var enableQueryServiceLogOTLPExport bool var preferSpanMetrics bool @@ -103,9 +103,10 @@ func main() { var maxOpenConns int var dialTimeout time.Duration var gatewayUrl string + var useLicensesV3 bool flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs") - flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses") + flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces") flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)") flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)") flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)") @@ -119,6 +120,7 @@ func main() { flag.BoolVar(&enableQueryServiceLogOTLPExport, "enable.query.service.log.otlp.export", false, "(enable query service log otlp export)") flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')") flag.StringVar(&gatewayUrl, "gateway-url", "", "(url to the gateway)") + flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses") flag.Parse() @@ -145,7 +147,7 @@ func main() { Cluster: cluster, GatewayUrl: gatewayUrl, UseLogsNewSchema: useLogsNewSchema, - UseLicensesV3: useLicensesV3, + UseTraceNewSchema: useTraceNewSchema, } // Read the jwt secret key diff --git a/ee/query-service/model/license.go b/ee/query-service/model/license.go index 2f9a0feeda7..706985f027d 100644 --- a/ee/query-service/model/license.go +++ b/ee/query-service/model/license.go @@ -247,3 +247,24 @@ func NewLicenseV3WithIDAndKey(id string, key string, data map[string]interface{} licenseDataWithIdAndKey["key"] = key return NewLicenseV3(licenseDataWithIdAndKey) } + +func ConvertLicenseV3ToLicenseV2(l *LicenseV3) *License { + planKeyFromPlanName, ok := MapOldPlanKeyToNewPlanName[l.PlanName] + if !ok { + planKeyFromPlanName = Basic + } + return &License{ + Key: l.Key, + ActivationId: "", + PlanDetails: "", + FeatureSet: l.Features, + ValidationMessage: "", + IsCurrent: l.IsCurrent, + LicensePlan: LicensePlan{ + PlanKey: planKeyFromPlanName, + ValidFrom: l.ValidFrom, + ValidUntil: l.ValidUntil, + Status: l.Status}, + } + +} diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 1ac9ac28d6e..cb9760180b1 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -16,6 +16,10 @@ var ( PlanNameBasic = "BASIC" ) +var ( + MapOldPlanKeyToNewPlanName map[string]string = map[string]string{PlanNameBasic: Basic, PlanNameTeams: Pro, PlanNameEnterprise: Enterprise} +) + var ( LicenseStatusInactive = "INACTIVE" ) diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index 08ff3afcda3..ceec23747e2 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -61,6 +61,11 @@ func NewAnomalyRule( zap.L().Info("creating new AnomalyRule", zap.String("id", id), zap.Any("opts", opts)) + if p.RuleCondition.CompareOp == baserules.ValueIsBelow { + target := -1 * *p.RuleCondition.Target + p.RuleCondition.Target = &target + } + baseRule, err := baserules.NewBaseRule(id, p, reader, opts...) if err != nil { return nil, err diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go index 9843d108d8c..00e0882f367 100644 --- a/ee/query-service/rules/manager.go +++ b/ee/query-service/rules/manager.go @@ -26,6 +26,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) opts.FF, opts.Reader, opts.UseLogsNewSchema, + opts.UseTraceNewSchema, baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay), ) @@ -122,6 +123,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap opts.FF, opts.Reader, opts.UseLogsNewSchema, + opts.UseTraceNewSchema, baserules.WithSendAlways(), baserules.WithSendUnmatched(), ) diff --git a/frontend/package.json b/frontend/package.json index 320fa28f85b..bbfd1c85647 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,8 +40,8 @@ "@monaco-editor/react": "^4.3.1", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-tooltip": "1.0.7", - "@sentry/react": "7.102.1", - "@sentry/webpack-plugin": "2.16.0", + "@sentry/react": "8.41.0", + "@sentry/webpack-plugin": "2.22.6", "@signozhq/design-tokens": "1.1.4", "@uiw/react-md-editor": "3.23.5", "@visx/group": "3.3.0", @@ -76,7 +76,7 @@ "fontfaceobserver": "2.3.0", "history": "4.10.1", "html-webpack-plugin": "5.5.0", - "http-proxy-middleware": "2.0.7", + "http-proxy-middleware": "3.0.3", "i18next": "^21.6.12", "i18next-browser-languagedetector": "^6.1.3", "i18next-http-backend": "^1.3.2", @@ -128,7 +128,7 @@ "uuid": "^8.3.2", "web-vitals": "^0.2.4", "webpack": "5.94.0", - "webpack-dev-server": "^4.15.1", + "webpack-dev-server": "^4.15.2", "webpack-retry-chunk-load-plugin": "3.1.1", "xstate": "^4.31.0" }, @@ -241,6 +241,7 @@ "semver": "7.5.4", "xml2js": "0.5.0", "phin": "^3.7.1", - "body-parser": "1.20.3" + "body-parser": "1.20.3", + "http-proxy-middleware": "3.0.3" } } diff --git a/frontend/public/Icons/broom.svg b/frontend/public/Icons/broom.svg new file mode 100644 index 00000000000..afb21213a4a --- /dev/null +++ b/frontend/public/Icons/broom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Icons/infraContainers.svg b/frontend/public/Icons/infraContainers.svg new file mode 100644 index 00000000000..8dc7ad0de40 --- /dev/null +++ b/frontend/public/Icons/infraContainers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Images/feature-graphic-correlation.svg b/frontend/public/Images/feature-graphic-correlation.svg new file mode 100644 index 00000000000..1bbeefb264c --- /dev/null +++ b/frontend/public/Images/feature-graphic-correlation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/locales/en-GB/failedPayment.json b/frontend/public/locales/en-GB/failedPayment.json new file mode 100644 index 00000000000..a624e47c7db --- /dev/null +++ b/frontend/public/locales/en-GB/failedPayment.json @@ -0,0 +1,12 @@ +{ + "workspaceSuspended": "Your workspace is locked", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "actionHeader": "Pay to continue", + "actionDescription": "Pay now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Settle your bill to continue", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en-GB/infraMonitoring.json b/frontend/public/locales/en-GB/infraMonitoring.json new file mode 100644 index 00000000000..304053b993f --- /dev/null +++ b/frontend/public/locales/en-GB/infraMonitoring.json @@ -0,0 +1,8 @@ +{ + "containers_visualization_message": "The ability to visualise containers is in active development and should be available to you soon.", + "processes_visualization_message": "The ability to visualise processes is in active development and should be available to you soon.", + "working_message": "We're working to extend infrastructure monitoring to take care of a bunch of different cases. Thank you for your patience.", + "waitlist_message": "Join the waitlist for early access.", + "waitlist_success_message": "We have received your request for early access. We will get back to you as soon as we launch the feature.", + "contact_support": "Contact Support" +} diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 6cfe6e02385..c74d82f0288 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -37,8 +37,10 @@ "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended", "SUPPORT": "SigNoz | Support", "DEFAULT": "Open source Observability Platform | SigNoz", "ALERT_HISTORY": "SigNoz | Alert Rule History", - "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview" + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", + "INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring" } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 72d9f138106..de1e3083fa2 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -7,5 +7,5 @@ "save": "Save", "edit": "Edit", "logged_in": "Logged In", - "pending_data_placeholder": "Just a bit of patience, just a little bit’s enough ⎯ we’re getting your {{dataSource}}!" + "pending_data_placeholder": "Retrieving your {{dataSource}}!" } diff --git a/frontend/public/locales/en/failedPayment.json b/frontend/public/locales/en/failedPayment.json new file mode 100644 index 00000000000..a624e47c7db --- /dev/null +++ b/frontend/public/locales/en/failedPayment.json @@ -0,0 +1,12 @@ +{ + "workspaceSuspended": "Your workspace is locked", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "actionHeader": "Pay to continue", + "actionDescription": "Pay now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Settle your bill to continue", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/infraMonitoring.json b/frontend/public/locales/en/infraMonitoring.json new file mode 100644 index 00000000000..304053b993f --- /dev/null +++ b/frontend/public/locales/en/infraMonitoring.json @@ -0,0 +1,8 @@ +{ + "containers_visualization_message": "The ability to visualise containers is in active development and should be available to you soon.", + "processes_visualization_message": "The ability to visualise processes is in active development and should be available to you soon.", + "working_message": "We're working to extend infrastructure monitoring to take care of a bunch of different cases. Thank you for your patience.", + "waitlist_message": "Join the waitlist for early access.", + "waitlist_success_message": "We have received your request for early access. We will get back to you as soon as we launch the feature.", + "contact_support": "Contact Support" +} diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 4d3e899d76d..4d903b7a40b 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -45,6 +45,7 @@ "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended", "SUPPORT": "SigNoz | Support", "LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views", "TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views", @@ -53,5 +54,6 @@ "INTEGRATIONS": "SigNoz | Integrations", "ALERT_HISTORY": "SigNoz | Alert Rule History", "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", - "MESSAGING_QUEUES": "SigNoz | Messaging Queues" + "MESSAGING_QUEUES": "SigNoz | Messaging Queues", + "INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring" } diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index 645c28095c7..77ec267922f 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -10,6 +10,7 @@ import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import { isEmpty, isNull } from 'lodash-es'; +import { useAppContext } from 'providers/App/App'; import { ReactChild, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; @@ -20,8 +21,10 @@ import { AppState } from 'store/reducers'; import { getInitialUserTokenRefreshToken } from 'store/utils'; import AppActions from 'types/actions'; import { UPDATE_USER_IS_FETCH } from 'types/actions/app'; +import { LicenseState, LicenseStatus } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; import AppReducer from 'types/reducer/app'; +import { isCloudUser } from 'utils/app'; import { routePermission } from 'utils/permission'; import routes, { @@ -48,6 +51,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { isFetchingOrgPreferences, } = useSelector((state) => state.app); + const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); + const mapRoutes = useMemo( () => new Map( @@ -76,6 +81,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const { t } = useTranslation(['common']); + const isCloudUserVal = isCloudUser(); + const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); const dispatch = useDispatch>(); @@ -143,6 +150,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const handleRedirectForOrgOnboarding = (key: string): void => { if ( isLoggedInState && + isCloudUserVal && !isFetchingOrgPreferences && !isLoadingOrgUsers && !isEmpty(orgUsers?.payload) && @@ -158,6 +166,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { history.push(ROUTES.ONBOARDING); } } + + if (!isCloudUserVal && key === 'ONBOARDING') { + history.push(ROUTES.APPLICATION); + } }; const handleUserLoginIfTokenPresent = async ( @@ -241,6 +253,33 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } }, [isFetchingLicensesData]); + const navigateToWorkSpaceSuspended = (route: any): void => { + const { path } = route; + + if (path && path !== ROUTES.WORKSPACE_SUSPENDED) { + history.push(ROUTES.WORKSPACE_SUSPENDED); + + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + } + }; + + useEffect(() => { + if (!isFetchingActiveLicenseV3 && activeLicenseV3) { + const shouldSuspendWorkspace = + activeLicenseV3.status === LicenseStatus.SUSPENDED && + activeLicenseV3.state === LicenseState.PAYMENT_FAILED; + + if (shouldSuspendWorkspace) { + navigateToWorkSpaceSuspended(currentRoute); + } + } + }, [isFetchingActiveLicenseV3, activeLicenseV3]); + useEffect(() => { if (org && org.length > 0 && org[0].id !== undefined) { setOrgData(org[0]); @@ -250,7 +289,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const handleRouting = (): void => { const showOrgOnboarding = shouldShowOnboarding(); - if (showOrgOnboarding && !isOnboardingComplete) { + if (showOrgOnboarding && !isOnboardingComplete && isCloudUserVal) { history.push(ROUTES.ONBOARDING); } else { history.push(ROUTES.APPLICATION); diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index c4cab694135..9fd759f40c2 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -22,6 +22,7 @@ import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; import posthog from 'posthog-js'; import AlertRuleProvider from 'providers/Alert'; +import { AppProvider } from 'providers/App/App'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -291,42 +292,44 @@ function App(): JSX.Element { }, []); return ( - - - - - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - - - - - - - - - - - + + + + + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 2def2ac11f6..e623357ab55 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -206,6 +206,13 @@ export const WorkspaceBlocked = Loadable( import(/* webpackChunkName: "WorkspaceLocked" */ 'pages/WorkspaceLocked'), ); +export const WorkspaceSuspended = Loadable( + () => + import( + /* webpackChunkName: "WorkspaceSuspended" */ 'pages/WorkspaceSuspended/WorkspaceSuspended' + ), +); + export const ShortcutsPage = Loadable( () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'), ); @@ -228,3 +235,10 @@ export const MQDetailPage = Loadable( /* webpackChunkName: "MQDetailPage" */ 'pages/MessagingQueues/MQDetailPage' ), ); + +export const InfrastructureMonitoring = Loadable( + () => + import( + /* webpackChunkName: "InfrastructureMonitoring" */ 'pages/InfrastructureMonitoring' + ), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index ccbb700387b..480d03561b1 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -15,6 +15,7 @@ import { EditAlertChannelsAlerts, EditRulesPage, ErrorDetails, + InfrastructureMonitoring, IngestionSettings, InstalledIntegrations, LicensePage, @@ -52,6 +53,7 @@ import { UnAuthorized, UsageExplorerPage, WorkspaceBlocked, + WorkspaceSuspended, } from './pageComponents'; const routes: AppRoutes[] = [ @@ -363,6 +365,13 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'WORKSPACE_LOCKED', }, + { + path: ROUTES.WORKSPACE_SUSPENDED, + exact: true, + component: WorkspaceSuspended, + isPrivate: true, + key: 'WORKSPACE_SUSPENDED', + }, { path: ROUTES.SHORTCUTS, exact: true, @@ -391,6 +400,13 @@ const routes: AppRoutes[] = [ key: 'MESSAGING_QUEUES_DETAIL', isPrivate: true, }, + { + path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS, + exact: true, + component: InfrastructureMonitoring, + key: 'INFRASTRUCTURE_MONITORING_HOSTS', + isPrivate: true, + }, ]; export const SUPPORT_ROUTE: AppRoutes = { diff --git a/frontend/src/api/infra/getHostAttributeKeys.ts b/frontend/src/api/infra/getHostAttributeKeys.ts new file mode 100644 index 00000000000..4ab03ea8eb1 --- /dev/null +++ b/frontend/src/api/infra/getHostAttributeKeys.ts @@ -0,0 +1,37 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError, AxiosResponse } from 'axios'; +import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder'; +import { createIdFromObjectFields } from 'lib/createIdFromObjectFields'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + BaseAutocompleteData, + IQueryAutocompleteResponse, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; + +export const getHostAttributeKeys = async ( + searchText = '', +): Promise | ErrorResponse> => { + try { + const response: AxiosResponse<{ + data: IQueryAutocompleteResponse; + }> = await ApiBaseInstance.get( + `/hosts/attribute_keys?dataSource=metrics&searchText=${searchText}`, + ); + + const payload: BaseAutocompleteData[] = + response.data.data.attributeKeys?.map(({ id: _, ...item }) => ({ + ...item, + id: createIdFromObjectFields(item, baseAutoCompleteIdKeysOrder), + })) || []; + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: { attributeKeys: payload }, + }; + } catch (e) { + return ErrorResponseHandler(e as AxiosError); + } +}; diff --git a/frontend/src/api/infraMonitoring/getHostLists.ts b/frontend/src/api/infraMonitoring/getHostLists.ts new file mode 100644 index 00000000000..870f87f9d6a --- /dev/null +++ b/frontend/src/api/infraMonitoring/getHostLists.ts @@ -0,0 +1,77 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; + +export interface HostListPayload { + filters: TagFilter; + groupBy: BaseAutocompleteData[]; + offset?: number; + limit?: number; + orderBy?: { + columnName: string; + order: 'asc' | 'desc'; + }; +} + +export interface TimeSeriesValue { + timestamp: number; + value: string; +} + +export interface TimeSeries { + labels: Record; + labelsArray: Array>; + values: TimeSeriesValue[]; +} + +export interface HostData { + hostName: string; + active: boolean; + os: string; + cpu: number; + cpuTimeSeries: TimeSeries; + memory: number; + memoryTimeSeries: TimeSeries; + wait: number; + waitTimeSeries: TimeSeries; + load15: number; + load15TimeSeries: TimeSeries; +} + +export interface HostListResponse { + status: string; + data: { + type: string; + records: HostData[]; + groups: null; + total: number; + sentAnyHostMetricsData: boolean; + isSendingK8SAgentMetrics: boolean; + }; +} + +export const getHostLists = async ( + props: HostListPayload, + signal?: AbortSignal, + headers?: Record, +): Promise | ErrorResponse> => { + try { + const response = await ApiBaseInstance.post('/hosts/list', props, { + signal, + headers, + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data, + params: props, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; diff --git a/frontend/src/api/infraMonitoring/getInfraAttributeValues.ts b/frontend/src/api/infraMonitoring/getInfraAttributeValues.ts new file mode 100644 index 00000000000..10488af1322 --- /dev/null +++ b/frontend/src/api/infraMonitoring/getInfraAttributeValues.ts @@ -0,0 +1,38 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import createQueryParams from 'lib/createQueryParams'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + IAttributeValuesResponse, + IGetAttributeValuesPayload, +} from 'types/api/queryBuilder/getAttributesValues'; + +export const getInfraAttributesValues = async ({ + dataSource, + attributeKey, + filterAttributeKeyDataType, + tagType, + searchText, +}: IGetAttributeValuesPayload): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await ApiBaseInstance.get( + `/hosts/attribute_values?${createQueryParams({ + dataSource, + attributeKey, + searchText, + })}&filterAttributeKeyDataType=${filterAttributeKeyDataType}&tagType=${tagType}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; diff --git a/frontend/src/api/licensesV3/getActive.ts b/frontend/src/api/licensesV3/getActive.ts new file mode 100644 index 00000000000..48dd0a3a434 --- /dev/null +++ b/frontend/src/api/licensesV3/getActive.ts @@ -0,0 +1,18 @@ +import { ApiV3Instance as axios } from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { LicenseV3EventQueueResModel } from 'types/api/licensesV3/getActive'; + +const getActive = async (): Promise< + SuccessResponse | ErrorResponse +> => { + const response = await axios.get('/licenses/active'); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default getActive; diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts b/frontend/src/api/messagingQueues/getTopicThroughputOverview.ts similarity index 89% rename from frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts rename to frontend/src/api/messagingQueues/getTopicThroughputOverview.ts index ac955e84053..072f5eccf2f 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/getTopicThroughputOverview.ts +++ b/frontend/src/api/messagingQueues/getTopicThroughputOverview.ts @@ -1,10 +1,9 @@ import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; - import { MessagingQueueServicePayload, MessagingQueuesPayloadProps, -} from './getConsumerLagDetails'; +} from 'pages/MessagingQueues/MQDetails/MQTables/getConsumerLagDetails'; +import { ErrorResponse, SuccessResponse } from 'types/api'; export const getTopicThroughputOverview = async ( props: Omit, diff --git a/frontend/src/api/queryBuilder/getAttributeKeys.ts b/frontend/src/api/queryBuilder/getAttributeKeys.ts index 9cc127bb716..f9b84ea2a94 100644 --- a/frontend/src/api/queryBuilder/getAttributeKeys.ts +++ b/frontend/src/api/queryBuilder/getAttributeKeys.ts @@ -5,7 +5,6 @@ import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder'; import { createIdFromObjectFields } from 'lib/createIdFromObjectFields'; import createQueryParams from 'lib/createQueryParams'; import { ErrorResponse, SuccessResponse } from 'types/api'; -// ** Types import { IGetAttributeKeysPayload } from 'types/api/queryBuilder/getAttributeKeys'; import { BaseAutocompleteData, diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss index 14f80a9b933..022a193761c 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss @@ -40,7 +40,7 @@ &.custom-time { input:not(:focus) { - min-width: 240px; + min-width: 280px; } } @@ -119,3 +119,69 @@ color: var(--bg-slate-400) !important; } } + +.date-time-popover__footer { + border-top: 1px solid var(--bg-ink-200); + padding: 8px 14px; + .timezone-container { + &, + .timezone { + font-family: Inter; + font-size: 12px; + line-height: 16px; + letter-spacing: -0.06px; + } + display: flex; + align-items: center; + color: var(--bg-vanilla-400); + gap: 6px; + .timezone { + display: flex; + align-items: center; + gap: 4px; + border-radius: 2px; + background: rgba(171, 189, 255, 0.04); + cursor: pointer; + padding: 0px 4px; + color: var(--bg-vanilla-100); + border: none; + } + } +} +.timezone-badge { + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border-radius: 2px; + background: rgba(171, 189, 255, 0.04); + color: var(--bg-vanilla-100); + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: -0.06px; + cursor: pointer; +} + +.lightMode { + .date-time-popover__footer { + border-color: var(--bg-vanilla-400); + } + .timezone-container { + color: var(--bg-ink-400); + &__clock-icon { + stroke: var(--bg-ink-400); + } + .timezone { + color: var(--bg-ink-100); + background: rgb(179 179 179 / 15%); + &__icon { + stroke: var(--bg-ink-100); + } + } + } + .timezone-badge { + color: var(--bg-ink-100); + background: rgb(179 179 179 / 15%); + } +} diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx index a3bb9801757..6064b64a090 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx @@ -15,11 +15,14 @@ import { isValidTimeFormat } from 'lib/getMinMax'; import { defaultTo, isFunction, noop } from 'lodash-es'; import debounce from 'lodash-es/debounce'; import { CheckCircle, ChevronDown, Clock } from 'lucide-react'; +import { useTimezone } from 'providers/Timezone'; import { ChangeEvent, Dispatch, SetStateAction, + useCallback, useEffect, + useMemo, useState, } from 'react'; import { useLocation } from 'react-router-dom'; @@ -28,6 +31,8 @@ import { popupContainer } from 'utils/selectPopupContainer'; import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent'; const maxAllowedMinTimeInMonths = 6; +type ViewType = 'datetime' | 'timezone'; +const DEFAULT_VIEW: ViewType = 'datetime'; interface CustomTimePickerProps { onSelect: (value: string) => void; @@ -81,11 +86,42 @@ function CustomTimePicker({ const location = useLocation(); const [isInputFocused, setIsInputFocused] = useState(false); + const [activeView, setActiveView] = useState(DEFAULT_VIEW); + + const { timezone, browserTimezone } = useTimezone(); + const activeTimezoneOffset = timezone.offset; + const isTimezoneOverridden = useMemo( + () => timezone.offset !== browserTimezone.offset, + [timezone, browserTimezone], + ); + + const handleViewChange = useCallback( + (newView: 'timezone' | 'datetime'): void => { + if (activeView !== newView) { + setActiveView(newView); + } + setOpen(true); + }, + [activeView, setOpen], + ); + + const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false); + const getSelectedTimeRangeLabel = ( selectedTime: string, selectedTimeValue: string, ): string => { if (selectedTime === 'custom') { + // Convert the date range string to 12-hour format + const dates = selectedTimeValue.split(' - '); + if (dates.length === 2) { + const startDate = dayjs(dates[0], 'DD/MM/YYYY HH:mm'); + const endDate = dayjs(dates[1], 'DD/MM/YYYY HH:mm'); + + return `${startDate.format('DD/MM/YYYY hh:mm A')} - ${endDate.format( + 'DD/MM/YYYY hh:mm A', + )}`; + } return selectedTimeValue; } @@ -120,7 +156,6 @@ function CustomTimePicker({ useEffect(() => { const value = getSelectedTimeRangeLabel(selectedTime, selectedValue); - setSelectedTimePlaceholderValue(value); }, [selectedTime, selectedValue]); @@ -132,6 +167,7 @@ function CustomTimePicker({ setOpen(newOpen); if (!newOpen) { setCustomDTPickerVisible?.(false); + setActiveView('datetime'); } }; @@ -245,6 +281,7 @@ function CustomTimePicker({ const handleFocus = (): void => { setIsInputFocused(true); + setActiveView('datetime'); }; const handleBlur = (): void => { @@ -281,6 +318,10 @@ function CustomTimePicker({ handleGoLive={defaultTo(handleGoLive, noop)} options={items} selectedTime={selectedTime} + activeView={activeView} + setActiveView={setActiveView} + setIsOpenedFromFooter={setIsOpenedFromFooter} + isOpenedFromFooter={isOpenedFromFooter} /> ) : ( content @@ -317,12 +358,24 @@ function CustomTimePicker({ ) } suffix={ - { - setOpen(!open); - }} - /> + <> + {!!isTimezoneOverridden && activeTimezoneOffset && ( +
{ + e.stopPropagation(); + handleViewChange('timezone'); + setIsOpenedFromFooter(false); + }} + > + {activeTimezoneOffset} +
+ )} + handleViewChange('datetime')} + /> + } /> diff --git a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx index 4a41bec4f5f..a42bb6b4780 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx @@ -1,5 +1,6 @@ import './CustomTimePicker.styles.scss'; +import { Color } from '@signozhq/design-tokens'; import { Button } from 'antd'; import cx from 'classnames'; import ROUTES from 'constants/routes'; @@ -9,10 +10,13 @@ import { Option, RelativeDurationSuggestionOptions, } from 'container/TopNav/DateTimeSelectionV2/config'; +import { Clock, PenLine } from 'lucide-react'; +import { useTimezone } from 'providers/Timezone'; import { Dispatch, SetStateAction, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import RangePickerModal from './RangePickerModal'; +import TimezonePicker from './TimezonePicker'; interface CustomTimePickerPopoverContentProps { options: any[]; @@ -26,8 +30,13 @@ interface CustomTimePickerPopoverContentProps { onSelectHandler: (label: string, value: string) => void; handleGoLive: () => void; selectedTime: string; + activeView: 'datetime' | 'timezone'; + setActiveView: Dispatch>; + isOpenedFromFooter: boolean; + setIsOpenedFromFooter: Dispatch>; } +// eslint-disable-next-line sonarjs/cognitive-complexity function CustomTimePickerPopoverContent({ options, setIsOpen, @@ -37,12 +46,18 @@ function CustomTimePickerPopoverContent({ onSelectHandler, handleGoLive, selectedTime, + activeView, + setActiveView, + isOpenedFromFooter, + setIsOpenedFromFooter, }: CustomTimePickerPopoverContentProps): JSX.Element { const { pathname } = useLocation(); const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [ pathname, ]); + const { timezone } = useTimezone(); + const activeTimezoneOffset = timezone.offset; function getTimeChips(options: Option[]): JSX.Element { return ( @@ -63,55 +78,99 @@ function CustomTimePickerPopoverContent({ ); } + const handleTimezoneHintClick = (): void => { + setActiveView('timezone'); + setIsOpenedFromFooter(true); + }; + + if (activeView === 'timezone') { + return ( +
+ +
+ ); + } + return ( -
-
- {isLogsExplorerPage && ( - - )} - {options.map((option) => ( - - ))} + <> +
+
+ {isLogsExplorerPage && ( + + )} + {options.map((option) => ( + + ))} +
+
+ {selectedTime === 'custom' || customDateTimeVisible ? ( + + ) : ( +
+
RELATIVE TIMES
+
{getTimeChips(RelativeDurationSuggestionOptions)}
+
+ )} +
-
- {selectedTime === 'custom' || customDateTimeVisible ? ( - +
+ - ) : ( -
-
RELATIVE TIMES
-
{getTimeChips(RelativeDurationSuggestionOptions)}
-
- )} + Current timezone +
⎯
+ +
-
+ ); } diff --git a/frontend/src/components/CustomTimePicker/RangePickerModal.tsx b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx index 24ba0e2b019..862d63e9227 100644 --- a/frontend/src/components/CustomTimePicker/RangePickerModal.tsx +++ b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx @@ -3,7 +3,8 @@ import './RangePickerModal.styles.scss'; import { DatePicker } from 'antd'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config'; -import dayjs, { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { useTimezone } from 'providers/Timezone'; import { Dispatch, SetStateAction } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -31,7 +32,10 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element { (state) => state.globalTime, ); - const disabledDate = (current: Dayjs): boolean => { + // Using any type here because antd's DatePicker expects its own internal Dayjs type + // which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc). + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + const disabledDate = (current: any): boolean => { const currentDay = dayjs(current); return currentDay.isAfter(dayjs()); }; @@ -49,16 +53,22 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element { } onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER); }; + + const { timezone } = useTimezone(); return (
diff --git a/frontend/src/components/CustomTimePicker/TimezonePicker.styles.scss b/frontend/src/components/CustomTimePicker/TimezonePicker.styles.scss new file mode 100644 index 00000000000..50ee1502ba6 --- /dev/null +++ b/frontend/src/components/CustomTimePicker/TimezonePicker.styles.scss @@ -0,0 +1,166 @@ +// Variables +$font-family: 'Inter'; +$item-spacing: 8px; + +:root { + --border-color: var(--bg-slate-400); +} + +.lightMode { + --border-color: var(--bg-vanilla-400); +} + +// Mixins +@mixin text-style-base { + font-family: $font-family; + font-style: normal; + font-weight: 400; +} + +@mixin flex-center { + display: flex; + align-items: center; +} + +.timezone-picker { + width: 532px; + color: var(--bg-vanilla-400); + font-family: $font-family; + + &__search { + @include flex-center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + } + + &__input-container { + @include flex-center; + gap: 6px; + width: -webkit-fill-available; + } + + &__input { + @include text-style-base; + width: 100%; + background: transparent; + border: none; + outline: none; + color: var(--bg-vanilla-100); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + padding: 0; + &.ant-input:focus { + box-shadow: none; + } + + &::placeholder { + color: var(--bg-vanilla-400); + } + } + + &__esc-key { + @include text-style-base; + font-size: 8px; + color: var(--bg-vanilla-400); + letter-spacing: -0.04px; + border-radius: 2.286px; + border: 1.143px solid var(--bg-ink-200); + border-bottom-width: 2.286px; + background: var(--bg-ink-400); + padding: 0 1px; + } + + &__list { + max-height: 310px; + overflow-y: auto; + } + + &__item { + @include flex-center; + justify-content: space-between; + padding: 7.5px 6px 7.5px $item-spacing; + margin: 4px $item-spacing; + cursor: pointer; + background: transparent; + border: none; + width: -webkit-fill-available; + color: var(--bg-vanilla-400); + font-family: $font-family; + + &:hover, + &.selected { + border-radius: 2px; + background: rgba(171, 189, 255, 0.04); + color: var(--bg-vanilla-100); + } + + &.has-divider { + position: relative; + &::after { + content: ''; + position: absolute; + bottom: -2px; + left: -$item-spacing; + right: -$item-spacing; + border-bottom: 1px solid var(--border-color); + } + } + } + + &__name { + @include text-style-base; + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + } + + &__offset { + color: var(--bg-vanilla-100); + font-size: 12px; + line-height: 16px; + letter-spacing: -0.06px; + } +} + +.timezone-name-wrapper { + @include flex-center; + gap: 6px; + + &__selected-icon { + height: 15px; + width: 15px; + } +} + +.lightMode { + .timezone-picker { + &__search { + .search-icon { + stroke: var(--bg-ink-400); + } + } + &__input { + color: var(--bg-ink-100); + } + &__esc-key { + background-color: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-400); + color: var(--bg-ink-400); + } + &__item { + color: var(--bg-ink-400); + } + &__offset { + color: var(--bg-ink-100); + } + } + .timezone-name-wrapper { + &__selected-icon { + .check-icon { + stroke: var(--bg-ink-100); + } + } + } +} diff --git a/frontend/src/components/CustomTimePicker/TimezonePicker.tsx b/frontend/src/components/CustomTimePicker/TimezonePicker.tsx new file mode 100644 index 00000000000..2f4da458379 --- /dev/null +++ b/frontend/src/components/CustomTimePicker/TimezonePicker.tsx @@ -0,0 +1,201 @@ +import './TimezonePicker.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Input } from 'antd'; +import cx from 'classnames'; +import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts'; +import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; +import { Check, Search } from 'lucide-react'; +import { useTimezone } from 'providers/Timezone'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react'; + +import { Timezone, TIMEZONE_DATA } from './timezoneUtils'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + setIsOpen: Dispatch>; + setActiveView: Dispatch>; + isOpenedFromFooter: boolean; +} + +interface TimezoneItemProps { + timezone: Timezone; + isSelected?: boolean; + onClick?: () => void; +} + +const ICON_SIZE = 14; + +function SearchBar({ + value, + onChange, + setIsOpen, + setActiveView, + isOpenedFromFooter = false, +}: SearchBarProps): JSX.Element { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + if (isOpenedFromFooter) { + setActiveView('datetime'); + } else { + setIsOpen(false); + } + } + }, + [setActiveView, setIsOpen, isOpenedFromFooter], + ); + + return ( +
+
+ + onChange(e.target.value)} + onKeyDown={handleKeyDown} + tabIndex={0} + autoFocus + /> +
+ esc +
+ ); +} + +function TimezoneItem({ + timezone, + isSelected = false, + onClick, +}: TimezoneItemProps): JSX.Element { + return ( + + ); +} + +TimezoneItem.defaultProps = { + isSelected: false, + onClick: undefined, +}; + +interface TimezonePickerProps { + setActiveView: Dispatch>; + setIsOpen: Dispatch>; + isOpenedFromFooter: boolean; +} + +function TimezonePicker({ + setActiveView, + setIsOpen, + isOpenedFromFooter, +}: TimezonePickerProps): JSX.Element { + console.log({ isOpenedFromFooter }); + const [searchTerm, setSearchTerm] = useState(''); + const { timezone, updateTimezone } = useTimezone(); + const [selectedTimezone, setSelectedTimezone] = useState( + timezone.name ?? TIMEZONE_DATA[0].name, + ); + + const getFilteredTimezones = useCallback((searchTerm: string): Timezone[] => { + const normalizedSearch = searchTerm.toLowerCase(); + return TIMEZONE_DATA.filter( + (tz) => + tz.name.toLowerCase().includes(normalizedSearch) || + tz.offset.toLowerCase().includes(normalizedSearch) || + tz.searchIndex.toLowerCase().includes(normalizedSearch), + ); + }, []); + + const handleCloseTimezonePicker = useCallback(() => { + if (isOpenedFromFooter) { + setActiveView('datetime'); + } else { + setIsOpen(false); + } + }, [isOpenedFromFooter, setActiveView, setIsOpen]); + + const handleTimezoneSelect = useCallback( + (timezone: Timezone) => { + setSelectedTimezone(timezone.name); + updateTimezone(timezone); + handleCloseTimezonePicker(); + setIsOpen(false); + }, + [handleCloseTimezonePicker, setIsOpen, updateTimezone], + ); + + // Register keyboard shortcuts + const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); + + useEffect(() => { + registerShortcut( + TimezonePickerShortcuts.CloseTimezonePicker, + handleCloseTimezonePicker, + ); + + return (): void => { + deregisterShortcut(TimezonePickerShortcuts.CloseTimezonePicker); + }; + }, [deregisterShortcut, handleCloseTimezonePicker, registerShortcut]); + + return ( +
+ +
+ {getFilteredTimezones(searchTerm).map((timezone) => ( + handleTimezoneSelect(timezone)} + /> + ))} +
+
+ ); +} + +export default TimezonePicker; diff --git a/frontend/src/components/CustomTimePicker/timezoneUtils.ts b/frontend/src/components/CustomTimePicker/timezoneUtils.ts new file mode 100644 index 00000000000..92da405ba48 --- /dev/null +++ b/frontend/src/components/CustomTimePicker/timezoneUtils.ts @@ -0,0 +1,152 @@ +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export interface Timezone { + name: string; + value: string; + offset: string; + searchIndex: string; + hasDivider?: boolean; +} + +const TIMEZONE_TYPES = { + BROWSER: 'BROWSER', + UTC: 'UTC', + STANDARD: 'STANDARD', +} as const; + +type TimezoneType = typeof TIMEZONE_TYPES[keyof typeof TIMEZONE_TYPES]; + +export const UTC_TIMEZONE: Timezone = { + name: 'Coordinated Universal Time — UTC, GMT', + value: 'UTC', + offset: 'UTC', + searchIndex: 'UTC', + hasDivider: true, +}; + +const normalizeTimezoneName = (timezone: string): string => { + // https://github.com/tc39/proposal-temporal/issues/1076 + if (timezone === 'Asia/Calcutta') { + return 'Asia/Kolkata'; + } + return timezone; +}; + +const formatOffset = (offsetMinutes: number): string => { + if (offsetMinutes === 0) return 'UTC'; + + const hours = Math.floor(Math.abs(offsetMinutes) / 60); + const minutes = Math.abs(offsetMinutes) % 60; + const sign = offsetMinutes > 0 ? '+' : '-'; + + return `UTC ${sign} ${hours}${ + minutes ? `:${minutes.toString().padStart(2, '0')}` : ':00' + }`; +}; + +const createTimezoneEntry = ( + name: string, + offsetMinutes: number, + type: TimezoneType = TIMEZONE_TYPES.STANDARD, + hasDivider = false, +): Timezone => { + const offset = formatOffset(offsetMinutes); + let value = name; + let displayName = name; + + switch (type) { + case TIMEZONE_TYPES.BROWSER: + displayName = `Browser time — ${name}`; + value = name; + break; + case TIMEZONE_TYPES.UTC: + displayName = 'Coordinated Universal Time — UTC, GMT'; + value = 'UTC'; + break; + case TIMEZONE_TYPES.STANDARD: + displayName = name; + value = name; + break; + default: + console.error(`Invalid timezone type: ${type}`); + } + + return { + name: displayName, + value, + offset, + searchIndex: offset.replace(/ /g, ''), + ...(hasDivider && { hasDivider }), + }; +}; + +const getOffsetByTimezone = (timezone: string): number => { + const dayjsTimezone = dayjs().tz(timezone); + return dayjsTimezone.utcOffset(); +}; + +export const getBrowserTimezone = (): Timezone => { + const browserTz = dayjs.tz.guess(); + const normalizedTz = normalizeTimezoneName(browserTz); + const browserOffset = getOffsetByTimezone(normalizedTz); + return createTimezoneEntry( + normalizedTz, + browserOffset, + TIMEZONE_TYPES.BROWSER, + ); +}; + +const filterAndSortTimezones = ( + allTimezones: string[], + browserTzName?: string, + includeEtcTimezones = false, +): Timezone[] => + allTimezones + .filter((tz) => { + const isNotBrowserTz = tz !== browserTzName; + const isNotEtcTz = includeEtcTimezones || !tz.startsWith('Etc/'); + return isNotBrowserTz && isNotEtcTz; + }) + .sort((a, b) => a.localeCompare(b)) + .map((tz) => { + const normalizedTz = normalizeTimezoneName(tz); + const offset = getOffsetByTimezone(normalizedTz); + return createTimezoneEntry(normalizedTz, offset); + }); + +const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allTimezones = (Intl as any).supportedValuesOf('timeZone'); + const timezones: Timezone[] = []; + + // Add browser timezone + const browserTzObject = getBrowserTimezone(); + timezones.push(browserTzObject); + + // Add UTC timezone with divider + timezones.push(UTC_TIMEZONE); + + timezones.push( + ...filterAndSortTimezones( + allTimezones, + browserTzObject.value, + includeEtcTimezones, + ), + ); + + return timezones; +}; + +export const getTimezoneObjectByTimezoneString = ( + timezone: string, +): Timezone => { + const utcOffset = getOffsetByTimezone(timezone); + return createTimezoneEntry(timezone, utcOffset); +}; + +export const TIMEZONE_DATA = generateTimezoneData(); diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index 0065f6b33c3..6358ebfe7a8 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -1,4 +1,5 @@ import { + _adapters, BarController, BarElement, CategoryScale, @@ -18,8 +19,10 @@ import { } from 'chart.js'; import annotationPlugin from 'chartjs-plugin-annotation'; import { generateGridTitle } from 'container/GridPanelSwitch/utils'; +import dayjs from 'dayjs'; import { useIsDarkMode } from 'hooks/useDarkMode'; import isEqual from 'lodash-es/isEqual'; +import { useTimezone } from 'providers/Timezone'; import { forwardRef, memo, @@ -62,6 +65,17 @@ Chart.register( Tooltip.positioners.custom = TooltipPositionHandler; +// Map of Chart.js time formats to dayjs format strings +const formatMap = { + 'HH:mm:ss': 'HH:mm:ss', + 'HH:mm': 'HH:mm', + 'MM/DD HH:mm': 'MM/DD HH:mm', + 'MM/dd HH:mm': 'MM/DD HH:mm', + 'MM/DD': 'MM/DD', + 'YY-MM': 'YY-MM', + YY: 'YY', +}; + const Graph = forwardRef( ( { @@ -80,11 +94,13 @@ const Graph = forwardRef( dragSelectColor, }, ref, + // eslint-disable-next-line sonarjs/cognitive-complexity ): JSX.Element => { const nearestDatasetIndex = useRef(null); const chartRef = useRef(null); const isDarkMode = useIsDarkMode(); const gridTitle = useMemo(() => generateGridTitle(title), [title]); + const { timezone } = useTimezone(); const currentTheme = isDarkMode ? 'dark' : 'light'; const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data @@ -112,6 +128,22 @@ const Graph = forwardRef( return 'rgba(231,233,237,0.8)'; }, [currentTheme]); + // Override Chart.js date adapter to use dayjs with timezone support + useEffect(() => { + _adapters._date.override({ + format(time: number | Date, fmt: string) { + const dayjsTime = dayjs(time).tz(timezone.value); + const format = formatMap[fmt as keyof typeof formatMap]; + if (!format) { + console.warn(`Missing datetime format for ${fmt}`); + return dayjsTime.format('YYYY-MM-DD HH:mm:ss'); // fallback format + } + + return dayjsTime.format(format); + }, + }); + }, [timezone]); + const buildChart = useCallback(() => { if (lineChartRef.current !== undefined) { lineChartRef.current.destroy(); @@ -132,6 +164,7 @@ const Graph = forwardRef( isStacked, onClickHandler, data, + timezone, ); const chartHasData = hasData(data); @@ -166,6 +199,7 @@ const Graph = forwardRef( isStacked, onClickHandler, data, + timezone, name, type, ]); diff --git a/frontend/src/components/Graph/utils.ts b/frontend/src/components/Graph/utils.ts index f002d1402fa..603f3875663 100644 --- a/frontend/src/components/Graph/utils.ts +++ b/frontend/src/components/Graph/utils.ts @@ -1,5 +1,6 @@ import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js'; import * as chartjsAdapter from 'chartjs-adapter-date-fns'; +import { Timezone } from 'components/CustomTimePicker/timezoneUtils'; import dayjs from 'dayjs'; import { MutableRefObject } from 'react'; @@ -50,6 +51,7 @@ export const getGraphOptions = ( isStacked: boolean | undefined, onClickHandler: GraphOnClickHandler | undefined, data: ChartData, + timezone: Timezone, // eslint-disable-next-line sonarjs/cognitive-complexity ): CustomChartOptions => ({ animation: { @@ -97,7 +99,7 @@ export const getGraphOptions = ( callbacks: { title(context): string | string[] { const date = dayjs(context[0].parsed.x); - return date.format('MMM DD, YYYY, HH:mm:ss'); + return date.tz(timezone.value).format('MMM DD, YYYY, HH:mm:ss'); }, label(context): string | string[] { let label = context.dataset.label || ''; diff --git a/frontend/src/components/HostMetricsDetail/Containers/Containers.styles.scss b/frontend/src/components/HostMetricsDetail/Containers/Containers.styles.scss new file mode 100644 index 00000000000..925ec30f2a8 --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/Containers/Containers.styles.scss @@ -0,0 +1,66 @@ +.host-containers { + gap: 24px; + height: 60vh; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + .infra-container-card-container { + display: flex; + flex-direction: column; + gap: 24px; + } + + .dev-status-container { + display: flex; + flex-direction: column; + gap: 12px; + } + + .infra-container-card { + display: flex; + flex-direction: column; + justify-content: center; + } + + .infra-container-card-text { + font-size: var(--font-size-sm); + color: var(--text-vanilla-400); + line-height: 20px; + letter-spacing: -0.07px; + width: 400px; + font-family: 'Inter'; + margin-top: 12px; + font-weight: 300; + } + + .infra-container-working-msg { + display: flex; + width: 400px; + padding: 12px; + align-items: flex-start; + gap: 12px; + border-radius: 4px; + background: rgba(171, 189, 255, 0.04); + + .ant-space { + align-items: flex-start; + } + } + + .infra-container-contact-support-btn { + display: flex; + align-items: center; + justify-content: center; + margin: auto; + } +} + +.lightMode { + .infra-container-card-text { + color: var(--text-ink-200); + } +} diff --git a/frontend/src/components/HostMetricsDetail/Containers/Containers.tsx b/frontend/src/components/HostMetricsDetail/Containers/Containers.tsx new file mode 100644 index 00000000000..4dc01ff5e81 --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/Containers/Containers.tsx @@ -0,0 +1,44 @@ +import './Containers.styles.scss'; + +import { Space, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import WaitlistFragment from '../WaitlistFragment/WaitlistFragment'; + +const { Text } = Typography; + +function Containers(): JSX.Element { + const { t } = useTranslation(['infraMonitoring']); + + return ( + +
+
+
+ infra-container + + + {t('containers_visualization_message')} + +
+ +
+ + broom + {t('working_message')} + +
+
+ + +
+
+ ); +} + +export default Containers; diff --git a/frontend/src/components/HostMetricsDetail/HostMetricDetail.interfaces.ts b/frontend/src/components/HostMetricsDetail/HostMetricDetail.interfaces.ts new file mode 100644 index 00000000000..65d3bf44d89 --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricDetail.interfaces.ts @@ -0,0 +1,7 @@ +import { HostData } from 'api/infraMonitoring/getHostLists'; + +export type HostDetailProps = { + host: HostData | null; + isModalTimeSelection: boolean; + onClose: () => void; +}; diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.styles.scss b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.styles.scss new file mode 100644 index 00000000000..2bcabd1b30d --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.styles.scss @@ -0,0 +1,193 @@ +.host-metric-traces { + margin-top: 1rem; + + .host-metric-traces-header { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + + gap: 8px; + padding: 12px; + border-radius: 3px; + border: 1px solid var(--bg-slate-500); + + .filter-section { + flex: 1; + + .ant-select-selector { + border-radius: 2px; + border: 1px solid var(--bg-slate-400) !important; + background-color: var(--bg-ink-300) !important; + + input { + font-size: 12px; + } + + .ant-tag .ant-typography { + font-size: 12px; + } + } + } + } + + .host-metric-traces-table { + .ant-table-content { + overflow: hidden !important; + } + + .ant-table { + border-radius: 3px; + border: 1px solid var(--bg-slate-500); + + .ant-table-thead > tr > th { + padding: 12px; + font-weight: 500; + font-size: 12px; + line-height: 18px; + + background: rgba(171, 189, 255, 0.01); + border-bottom: none; + + color: var(--Vanilla-400, #c0c1c3); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.44px; + text-transform: uppercase; + + &::before { + background-color: transparent; + } + } + + .ant-table-thead > tr > th:has(.hostname-column-header) { + background: var(--bg-ink-400); + } + + .ant-table-cell { + padding: 12px; + font-size: 13px; + line-height: 20px; + color: var(--bg-vanilla-100); + background: rgba(171, 189, 255, 0.01); + } + + .ant-table-cell:has(.hostname-column-value) { + background: var(--bg-ink-400); + } + + .hostname-column-value { + color: var(--bg-vanilla-100); + font-family: 'Geist Mono'; + font-style: normal; + font-weight: 600; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + .status-cell { + .active-tag { + color: var(--bg-forest-500); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + } + + .progress-container { + .ant-progress-bg { + height: 8px !important; + border-radius: 4px; + } + } + + .ant-table-tbody > tr:hover > td { + background: rgba(255, 255, 255, 0.04); + } + + .ant-table-cell:first-child { + text-align: justify; + } + + .ant-table-cell:nth-child(2) { + padding-left: 16px; + padding-right: 16px; + } + + .ant-table-cell:nth-child(n + 3) { + padding-right: 24px; + } + .column-header-right { + text-align: right; + } + .ant-table-tbody > tr > td { + border-bottom: none; + } + + .ant-table-thead + > tr + > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before { + background-color: transparent; + } + + .ant-empty-normal { + visibility: hidden; + } + } + + .ant-table-container::after { + content: none; + } + } +} + +.lightMode { + .host-metric-traces-header { + .filter-section { + border-top: 1px solid var(--bg-vanilla-300); + border-bottom: 1px solid var(--bg-vanilla-300); + + .ant-select-selector { + border-color: var(--bg-vanilla-300) !important; + background-color: var(--bg-vanilla-100) !important; + color: var(--bg-ink-200); + } + } + } + + .host-metric-traces-table { + .ant-table { + border-radius: 3px; + border: 1px solid var(--bg-vanilla-300); + + .ant-table-thead > tr > th { + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .ant-table-thead > tr > th:has(.hostname-column-header) { + background: var(--bg-vanilla-100); + } + + .ant-table-cell { + background: var(--bg-vanilla-100); + color: var(--bg-ink-500); + } + + .ant-table-cell:has(.hostname-column-value) { + background: var(--bg-vanilla-100); + } + + .hostname-column-value { + color: var(--bg-ink-300); + } + + .ant-table-tbody > tr:hover > td { + background: rgba(0, 0, 0, 0.04); + } + } + } +} diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx new file mode 100644 index 00000000000..2d4b7af903d --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx @@ -0,0 +1,195 @@ +import './HostMetricTraces.styles.scss'; + +import { ResizeTable } from 'components/ResizeTable'; +import { DEFAULT_ENTITY_VERSION } from 'constants/app'; +import { QueryParams } from 'constants/query'; +import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch'; +import NoLogs from 'container/NoLogs/NoLogs'; +import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; +import { ErrorText } from 'container/TimeSeriesView/styles'; +import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; +import { + CustomTimeType, + Time, +} from 'container/TopNav/DateTimeSelectionV2/config'; +import TraceExplorerControls from 'container/TracesExplorer/Controls'; +import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs'; +import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { Pagination } from 'hooks/queryPagination'; +import useUrlQueryData from 'hooks/useUrlQueryData'; +import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +import { getHostTracesQueryPayload, selectedColumns } from './constants'; +import { getListColumns } from './utils'; + +interface Props { + timeRange: { + startTime: number; + endTime: number; + }; + isModalTimeSelection: boolean; + handleTimeChange: ( + interval: Time | CustomTimeType, + dateTimeRange?: [number, number], + ) => void; + handleChangeTracesFilters: (value: IBuilderQuery['filters']) => void; + tracesFilters: IBuilderQuery['filters']; + selectedInterval: Time; +} + +function HostMetricTraces({ + timeRange, + isModalTimeSelection, + handleTimeChange, + handleChangeTracesFilters, + tracesFilters, + selectedInterval, +}: Props): JSX.Element { + const [traces, setTraces] = useState([]); + const [offset] = useState(0); + + const { currentQuery } = useQueryBuilder(); + const updatedCurrentQuery = useMemo( + () => ({ + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + { + ...currentQuery.builder.queryData[0], + dataSource: DataSource.TRACES, + aggregateOperator: 'noop', + aggregateAttribute: { + ...currentQuery.builder.queryData[0].aggregateAttribute, + }, + }, + ], + }, + }), + [currentQuery], + ); + + const query = updatedCurrentQuery?.builder?.queryData[0] || null; + + const { queryData: paginationQueryData } = useUrlQueryData( + QueryParams.pagination, + ); + + const queryPayload = useMemo( + () => + getHostTracesQueryPayload( + timeRange.startTime, + timeRange.endTime, + paginationQueryData?.offset || offset, + tracesFilters, + ), + [ + timeRange.startTime, + timeRange.endTime, + offset, + tracesFilters, + paginationQueryData, + ], + ); + + const { data, isLoading, isFetching, isError } = useQuery({ + queryKey: [ + 'hostMetricTraces', + timeRange.startTime, + timeRange.endTime, + offset, + tracesFilters, + DEFAULT_ENTITY_VERSION, + paginationQueryData, + ], + queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION), + enabled: !!queryPayload, + }); + + const traceListColumns = getListColumns(selectedColumns); + + useEffect(() => { + if (data?.payload?.data?.newResult?.data?.result) { + const currentData = data.payload.data.newResult.data.result; + if (currentData.length > 0 && currentData[0].list) { + if (offset === 0) { + setTraces(currentData[0].list ?? []); + } else { + setTraces((prev) => [...prev, ...(currentData[0].list ?? [])]); + } + } + } + }, [data, offset]); + + const isDataEmpty = + !isLoading && !isFetching && !isError && traces.length === 0; + const hasAdditionalFilters = tracesFilters.items.length > 1; + + const totalCount = + data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0; + + return ( +
+
+
+ {query && ( + + )} +
+
+ +
+
+ + {isError && {data?.error || 'Something went wrong'}} + + {isLoading && traces.length === 0 && } + + {isDataEmpty && !hasAdditionalFilters && ( + + )} + + {isDataEmpty && hasAdditionalFilters && ( + + )} + + {!isError && traces.length > 0 && ( +
+ + +
+ )} +
+ ); +} + +export default HostMetricTraces; diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts b/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts new file mode 100644 index 00000000000..8e2a3d62388 --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts @@ -0,0 +1,200 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; +import { nanoToMilli } from 'utils/timeUtils'; + +export const columns = [ + { + dataIndex: 'timestamp', + key: 'timestamp', + title: 'Timestamp', + width: 200, + render: (timestamp: string): string => new Date(timestamp).toLocaleString(), + }, + { + title: 'Service Name', + dataIndex: ['data', 'serviceName'], + key: 'serviceName-string-tag', + width: 150, + }, + { + title: 'Name', + dataIndex: ['data', 'name'], + key: 'name-string-tag', + width: 145, + }, + { + title: 'Duration', + dataIndex: ['data', 'durationNano'], + key: 'durationNano-float64-tag', + width: 145, + render: (duration: number): string => `${nanoToMilli(duration)}ms`, + }, + { + title: 'HTTP Method', + dataIndex: ['data', 'httpMethod'], + key: 'httpMethod-string-tag', + width: 145, + }, + { + title: 'Status Code', + dataIndex: ['data', 'responseStatusCode'], + key: 'responseStatusCode-string-tag', + width: 145, + }, +]; + +export const selectedColumns: BaseAutocompleteData[] = [ + { + key: 'timestamp', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + }, + { + key: 'serviceName', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + }, + { + key: 'name', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + }, + { + key: 'durationNano', + dataType: DataTypes.Float64, + type: 'tag', + isColumn: true, + }, + { + key: 'httpMethod', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + }, + { + key: 'responseStatusCode', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + }, +]; + +export const getHostTracesQueryPayload = ( + start: number, + end: number, + offset = 0, + filters: IBuilderQuery['filters'], +): GetQueryResultsProps => ({ + query: { + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + { + dataSource: DataSource.TRACES, + queryName: 'A', + aggregateOperator: 'noop', + aggregateAttribute: { + id: '------false', + dataType: DataTypes.EMPTY, + key: '', + isColumn: false, + type: '', + isJSON: false, + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + functions: [], + filters, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [ + { + columnName: 'timestamp', + order: 'desc', + }, + ], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + id: '572f1d91-6ac0-46c0-b726-c21488b34434', + queryType: EQueryType.QUERY_BUILDER, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + params: { + dataSource: DataSource.TRACES, + }, + tableParams: { + pagination: { + limit: 10, + offset, + }, + selectColumns: [ + { + key: 'serviceName', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'serviceName--string--tag--true', + isIndexed: false, + }, + { + key: 'name', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + isIndexed: false, + }, + { + key: 'durationNano', + dataType: 'float64', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNano--float64--tag--true', + isIndexed: false, + }, + { + key: 'httpMethod', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpMethod--string--tag--true', + isIndexed: false, + }, + { + key: 'responseStatusCode', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'responseStatusCode--string--tag--true', + isIndexed: false, + }, + ], + }, +}); diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/utils.tsx b/frontend/src/components/HostMetricsDetail/HostMetricTraces/utils.tsx new file mode 100644 index 00000000000..aba284586ad --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/utils.tsx @@ -0,0 +1,84 @@ +import { Tag, Typography } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; +import { + BlockLink, + getTraceLink, +} from 'container/TracesExplorer/ListView/utils'; +import dayjs from 'dayjs'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +const keyToLabelMap: Record = { + timestamp: 'Timestamp', + serviceName: 'Service Name', + name: 'Name', + durationNano: 'Duration', + httpMethod: 'HTTP Method', + responseStatusCode: 'Status Code', +}; + +export const getListColumns = ( + selectedColumns: BaseAutocompleteData[], +): ColumnsType => { + const columns: ColumnsType = + selectedColumns.map(({ dataType, key, type }) => ({ + title: keyToLabelMap[key], + dataIndex: key, + key: `${key}-${dataType}-${type}`, + width: 145, + render: (value, item): JSX.Element => { + const itemData = item.data as any; + + if (key === 'timestamp') { + const date = + typeof value === 'string' + ? dayjs(value).format('YYYY-MM-DD HH:mm:ss.SSS') + : dayjs(value / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'); + + return ( + + {date} + + ); + } + + if (value === '') { + return ( + + N/A + + ); + } + + if (key === 'httpMethod' || key === 'responseStatusCode') { + return ( + + + {itemData[key]} + + + ); + } + + if (key === 'durationNano') { + const durationNano = itemData[key]; + + return ( + + {getMs(durationNano)}ms + + ); + } + + return ( + + {itemData[key]} + + ); + }, + responsive: ['md'], + })) || []; + + return columns; +}; diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss b/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss new file mode 100644 index 00000000000..511348c463c --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss @@ -0,0 +1,232 @@ +.host-detail-drawer { + border-left: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2); + + .ant-drawer-header { + padding: 8px 16px; + border-bottom: none; + + align-items: stretch; + + border-bottom: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + } + + .ant-drawer-close { + margin-inline-end: 0px; + } + + .ant-drawer-body { + display: flex; + flex-direction: column; + padding: 16px; + } + + .title { + color: var(--text-vanilla-400); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + .radio-button { + display: flex; + align-items: center; + justify-content: center; + padding-top: var(--padding-1); + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + } + + .host-detail-drawer__host { + .host-details-grid { + .labels-row, + .values-row { + display: grid; + grid-template-columns: 1fr 1.5fr 1.5fr 1.5fr; + gap: 30px; + align-items: center; + } + + .labels-row { + margin-bottom: 8px; + } + + .host-details-metadata-label { + color: var(--text-vanilla-400); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.44px; + text-transform: uppercase; + } + + .status-tag { + margin: 0; + + &.active { + color: var(--success-500); + background: var(--success-100); + border-color: var(--success-500); + } + + &.inactive { + color: var(--error-500); + background: var(--error-100); + border-color: var(--error-500); + } + } + + .progress-container { + width: 158px; + .ant-progress { + margin: 0; + + .ant-progress-text { + font-weight: 600; + } + } + } + + .ant-card { + &.ant-card-bordered { + border: 1px solid var(--bg-slate-500) !important; + } + } + } + } + + .tabs-and-search { + display: flex; + justify-content: space-between; + align-items: center; + margin: 16px 0; + + .action-btn { + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + } + } + + .views-tabs-container { + margin-top: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + + .views-tabs { + color: var(--text-vanilla-400); + + .view-title { + display: flex; + gap: var(--margin-2); + align-items: center; + justify-content: center; + font-size: var(--font-size-xs); + font-style: normal; + font-weight: var(--font-weight-normal); + } + + .tab { + border: 1px solid var(--bg-slate-400); + width: 114px; + } + + .tab::before { + background: var(--bg-slate-400); + } + + .selected_view { + background: var(--bg-slate-300); + color: var(--text-vanilla-100); + border: 1px solid var(--bg-slate-400); + } + + .selected_view::before { + background: var(--bg-slate-400); + } + } + + .compass-button { + width: 30px; + height: 30px; + + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + } + } + .ant-drawer-close { + padding: 0px; + } +} + +.lightMode { + .ant-drawer-header { + border-bottom: 1px solid var(--bg-vanilla-400); + background: var(--bg-vanilla-100); + } + + .host-detail-drawer { + .title { + color: var(--text-ink-300); + } + + .host-detail-drawer__host { + .ant-typography { + color: var(--text-ink-300); + background: transparent; + } + } + + .radio-button { + border: 1px solid var(--bg-vanilla-400); + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .views-tabs { + .tab { + background: var(--bg-vanilla-100); + } + + .selected_view { + background: var(--bg-vanilla-300); + border: 1px solid var(--bg-slate-300); + color: var(--text-ink-400); + } + + .selected_view::before { + background: var(--bg-vanilla-300); + border-left: 1px solid var(--bg-slate-300); + } + } + + .compass-button { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + } + + .tabs-and-search { + .action-btn { + border: 1px solid var(--bg-vanilla-400); + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + } + } +} diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx new file mode 100644 index 00000000000..ee61e687cb3 --- /dev/null +++ b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx @@ -0,0 +1,517 @@ +import './HostMetricsDetail.styles.scss'; + +import { Color, Spacing } from '@signozhq/design-tokens'; +import { + Button, + Divider, + Drawer, + Progress, + Radio, + Tag, + Typography, +} from 'antd'; +import { RadioChangeEvent } from 'antd/lib'; +import logEvent from 'api/common/logEvent'; +import { QueryParams } from 'constants/query'; +import { + initialQueryBuilderFormValuesMap, + initialQueryState, +} from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import { + CustomTimeType, + Time, +} from 'container/TopNav/DateTimeSelectionV2/config'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import GetMinMax from 'lib/getMinMax'; +import { + BarChart2, + ChevronsLeftRight, + Compass, + DraftingCompass, + Package2, + ScrollText, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + TagFilterItem, +} from 'types/api/queryBuilder/queryBuilderData'; +import { + LogsAggregatorOperator, + TracesAggregatorOperator, +} from 'types/common/queryBuilder'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { v4 as uuidv4 } from 'uuid'; + +import { VIEW_TYPES, VIEWS } from './constants'; +import Containers from './Containers/Containers'; +import { HostDetailProps } from './HostMetricDetail.interfaces'; +import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView'; +import HostMetricTraces from './HostMetricTraces/HostMetricTraces'; +import Metrics from './Metrics/Metrics'; +import Processes from './Processes/Processes'; + +// eslint-disable-next-line sonarjs/cognitive-complexity +function HostMetricsDetails({ + host, + onClose, + isModalTimeSelection, +}: HostDetailProps): JSX.Element { + const { maxTime, minTime, selectedTime } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + + const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [ + minTime, + ]); + const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [ + maxTime, + ]); + + const urlQuery = useUrlQuery(); + + const [modalTimeRange, setModalTimeRange] = useState(() => ({ + startTime: startMs, + endTime: endMs, + })); + + const [selectedInterval, setSelectedInterval] = useState