diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 573be5f290..ae8840a453 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,6 @@ /frontend/ @YounixM /frontend/src/container/MetricsApplication @srikanthccv /frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv -/deploy/ @prashant-shahi -/sample-apps/ @prashant-shahi -.github @prashant-shahi +/deploy/ @SigNoz/devops +/sample-apps/ @SigNoz/devops +.github @SigNoz/devops diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1d8d4e7b70..ba92f9f28f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,13 @@ on: - release/v* jobs: + check-no-ee-references: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run check + run: make check-no-ee-references + build-frontend: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 6869cf7fb7..7808f9d18e 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -158,6 +158,7 @@ jobs: echo 'SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env echo 'TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env echo 'TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env + echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env - name: Install dependencies working-directory: frontend run: yarn install diff --git a/.github/workflows/staging-deployment.yaml b/.github/workflows/staging-deployment.yaml index 455ecbce8c..a2ff80354f 100644 --- a/.github/workflows/staging-deployment.yaml +++ b/.github/workflows/staging-deployment.yaml @@ -30,6 +30,7 @@ jobs: GCP_PROJECT: ${{ secrets.GCP_PROJECT }} GCP_ZONE: ${{ secrets.GCP_ZONE }} GCP_INSTANCE: ${{ secrets.GCP_INSTANCE }} + CLOUDSDK_CORE_DISABLE_PROMPTS: 1 run: | read -r -d '' COMMAND <
diff --git a/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml b/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml index dd2b1bdf5b..4e8dc00b30 100644 --- a/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml +++ b/deploy/docker-swarm/clickhouse-setup/clickhouse-config.xml @@ -649,12 +649,12 @@ See https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/replication/#creating-replicated-tables --> - + diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index e6354bb35e..c584bb50e7 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -146,7 +146,7 @@ services: condition: on-failure query-service: - image: signoz/query-service:0.46.0 + image: signoz/query-service:0.49.1 command: [ "-config=/root/config/prometheus.yml", @@ -186,7 +186,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:0.46.0 + image: signoz/frontend:0.48.0 deploy: restart_policy: condition: on-failure @@ -199,7 +199,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector: - image: signoz/signoz-otel-collector:0.88.24 + image: signoz/signoz-otel-collector:0.102.2 command: [ "--config=/etc/otel-collector-config.yaml", @@ -211,6 +211,7 @@ services: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ./otel-collector-opamp-config.yaml:/etc/manager-config.yaml - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /:/hostfs:ro environment: - OTEL_RESOURCE_ATTRIBUTES=host.name={{.Node.Hostname}},os.type={{.Node.Platform.OS}},dockerswarm.service.name={{.Service.Name}},dockerswarm.task.name={{.Task.Name}} - DOCKER_MULTI_NODE_CLUSTER=false @@ -237,7 +238,7 @@ services: - query-service otel-collector-migrator: - image: signoz/signoz-schema-migrator:0.88.24 + image: signoz/signoz-schema-migrator:0.102.2 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml b/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml index e6fe7e2a6e..afa8291358 100644 --- a/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml +++ b/deploy/docker-swarm/clickhouse-setup/otel-collector-config.yaml @@ -36,6 +36,7 @@ receivers: # endpoint: 0.0.0.0:6832 hostmetrics: collection_interval: 30s + root_path: /hostfs scrapers: cpu: {} load: {} diff --git a/deploy/docker/clickhouse-setup/clickhouse-config.xml b/deploy/docker/clickhouse-setup/clickhouse-config.xml index f8213b6521..027f53f951 100644 --- a/deploy/docker/clickhouse-setup/clickhouse-config.xml +++ b/deploy/docker/clickhouse-setup/clickhouse-config.xml @@ -649,12 +649,12 @@ See https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/replication/#creating-replicated-tables --> - + diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index cf1e5f1ed4..17f7c3e4a3 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -66,7 +66,7 @@ services: - --storage.path=/data otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.24} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.2} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -81,7 +81,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.88.24 + image: signoz/signoz-otel-collector:0.102.2 command: [ "--config=/etc/otel-collector-config.yaml", @@ -93,6 +93,8 @@ services: volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ./otel-collector-opamp-config.yaml:/etc/manager-config.yaml + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /:/hostfs:ro environment: - OTEL_RESOURCE_ATTRIBUTES=host.name=signoz-host,os.type=linux ports: diff --git a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml index efd61986b5..8d4564af31 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml @@ -164,7 +164,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` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.46.0} + image: signoz/query-service:${DOCKER_TAG:-0.49.1} container_name: signoz-query-service command: [ @@ -204,7 +204,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.46.0} + image: signoz/frontend:${DOCKER_TAG:-0.49.1} container_name: signoz-frontend restart: on-failure depends_on: @@ -216,7 +216,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.24} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.2} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -230,7 +230,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.88.24} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.2} container_name: signoz-otel-collector command: [ @@ -244,6 +244,7 @@ services: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ./otel-collector-opamp-config.yaml:/etc/manager-config.yaml - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /:/hostfs:ro environment: - OTEL_RESOURCE_ATTRIBUTES=host.name=signoz-host,os.type=linux - DOCKER_MULTI_NODE_CLUSTER=false diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index 5e2096e38a..17a975e5a6 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -164,7 +164,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` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.46.0} + image: signoz/query-service:${DOCKER_TAG:-0.49.1} container_name: signoz-query-service command: [ @@ -203,7 +203,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.46.0} + image: signoz/frontend:${DOCKER_TAG:-0.49.1} container_name: signoz-frontend restart: on-failure depends_on: @@ -215,7 +215,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.88.24} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.2} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -229,7 +229,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.88.24} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.2} container_name: signoz-otel-collector command: [ @@ -243,6 +243,7 @@ services: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - ./otel-collector-opamp-config.yaml:/etc/manager-config.yaml - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /:/hostfs:ro environment: - OTEL_RESOURCE_ATTRIBUTES=host.name=signoz-host,os.type=linux - DOCKER_MULTI_NODE_CLUSTER=false diff --git a/deploy/docker/clickhouse-setup/otel-collector-config.yaml b/deploy/docker/clickhouse-setup/otel-collector-config.yaml index 10202ea9fa..6f30d42ad1 100644 --- a/deploy/docker/clickhouse-setup/otel-collector-config.yaml +++ b/deploy/docker/clickhouse-setup/otel-collector-config.yaml @@ -36,6 +36,7 @@ receivers: # endpoint: 0.0.0.0:6832 hostmetrics: collection_interval: 30s + root_path: /hostfs scrapers: cpu: {} load: {} diff --git a/deploy/docker/common/nginx-config.conf b/deploy/docker/common/nginx-config.conf index f7943e21aa..c87960d7b2 100644 --- a/deploy/docker/common/nginx-config.conf +++ b/deploy/docker/common/nginx-config.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 3301; server_name _; @@ -42,6 +47,14 @@ server { proxy_read_timeout 600s; } + location /ws { + proxy_pass http://query-service:8080/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade "websocket"; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + } + # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; diff --git a/deploy/install.sh b/deploy/install.sh index 1d4905b6f6..85c63c248d 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -389,7 +389,7 @@ trap bye EXIT URL="https://api.segment.io/v1/track" HEADER_1="Content-Type: application/json" -HEADER_2="Authorization: Basic NEdtb2E0aXhKQVVIeDJCcEp4c2p3QTFiRWZud0VlUno6" +HEADER_2="Authorization: Basic OWtScko3b1BDR1BFSkxGNlFqTVBMdDVibGpGaFJRQnI=" send_event() { error="" diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index be0cf1ec36..66b462e167 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -24,7 +24,6 @@ import ( type APIHandlerOptions struct { DataConnector interfaces.DataConnector SkipConfig *basemodel.SkipConfig - PreferDelta bool PreferSpanMetrics bool MaxIdleConns int MaxOpenConns int @@ -53,7 +52,6 @@ func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) { baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{ Reader: opts.DataConnector, SkipConfig: opts.SkipConfig, - PerferDelta: opts.PreferDelta, PreferSpanMetrics: opts.PreferSpanMetrics, MaxIdleConns: opts.MaxIdleConns, MaxOpenConns: opts.MaxOpenConns, diff --git a/ee/query-service/app/api/dashboard.go b/ee/query-service/app/api/dashboard.go index 0628ae18f6..51fe6c2ded 100644 --- a/ee/query-service/app/api/dashboard.go +++ b/ee/query-service/app/api/dashboard.go @@ -1,7 +1,9 @@ package api import ( + "errors" "net/http" + "strings" "github.com/gorilla/mux" "go.signoz.io/signoz/pkg/query-service/app/dashboards" @@ -29,6 +31,10 @@ func (ah *APIHandler) lockUnlockDashboard(w http.ResponseWriter, r *http.Request // Get the dashboard UUID from the request uuid := mux.Vars(r)["uuid"] + if strings.HasPrefix(uuid,"integration") { + RespondError(w, &model.ApiError{Typ: model.ErrorForbidden, Err: errors.New("dashboards created by integrations cannot be unlocked")}, "You are not authorized to lock/unlock this dashboard") + return + } dashboard, err := dashboards.GetDashboard(r.Context(), uuid) if err != nil { RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, err.Error()) diff --git a/ee/query-service/app/api/featureFlags.go b/ee/query-service/app/api/featureFlags.go index 22ee798bee..d5af0ab626 100644 --- a/ee/query-service/app/api/featureFlags.go +++ b/ee/query-service/app/api/featureFlags.go @@ -1,17 +1,48 @@ package api import ( + "encoding/json" + "errors" + "fmt" + "io" "net/http" + "time" + "go.signoz.io/signoz/ee/query-service/constants" basemodel "go.signoz.io/signoz/pkg/query-service/model" + "go.uber.org/zap" ) func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() featureSet, err := ah.FF().GetFeatureFlags() if err != nil { ah.HandleError(w, err, http.StatusInternalServerError) return } + + if constants.FetchFeatures == "true" { + zap.L().Debug("fetching license") + license, err := ah.LM().GetRepo().GetActiveLicense(ctx) + if err != nil { + zap.L().Error("failed to fetch license", zap.Error(err)) + } else if license == nil { + zap.L().Debug("no active license found") + } else { + licenseKey := license.Key + + zap.L().Debug("fetching zeus features") + zeusFeatures, err := fetchZeusFeatures(constants.ZeusFeaturesURL, licenseKey) + if err == nil { + zap.L().Debug("fetched zeus features", zap.Any("features", zeusFeatures)) + // merge featureSet and zeusFeatures in featureSet with higher priority to zeusFeatures + featureSet = MergeFeatureSets(zeusFeatures, featureSet) + } else { + zap.L().Error("failed to fetch zeus features", zap.Error(err)) + } + } + } + if ah.opts.PreferSpanMetrics { for idx := range featureSet { feature := &featureSet[idx] @@ -20,5 +51,96 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { } } } + ah.Respond(w, featureSet) } + +// fetchZeusFeatures makes an HTTP GET request to the /zeusFeatures endpoint +// and returns the FeatureSet. +func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) { + // Check if the URL is empty + if url == "" { + return nil, fmt.Errorf("url is empty") + } + + // Check if the licenseKey is empty + if licenseKey == "" { + return nil, fmt.Errorf("licenseKey is empty") + } + + // Creating an HTTP client with a timeout for better control + client := &http.Client{ + Timeout: 10 * time.Second, + } + // Creating a new GET request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Setting the custom header + req.Header.Set("X-Signoz-Cloud-Api-Key", licenseKey) + + // Making the GET request + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make GET request: %w", err) + } + defer func() { + if resp != nil { + resp.Body.Close() + } + }() + + // Check for non-OK status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %d %s", errors.New("received non-OK HTTP status code"), resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + // Reading and decoding the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var zeusResponse ZeusFeaturesResponse + if err := json.Unmarshal(body, &zeusResponse); err != nil { + return nil, fmt.Errorf("%w: %v", errors.New("failed to decode response body"), err) + } + + if zeusResponse.Status != "success" { + return nil, fmt.Errorf("%w: %s", errors.New("failed to fetch zeus features"), zeusResponse.Status) + } + + return zeusResponse.Data, nil +} + +type ZeusFeaturesResponse struct { + Status string `json:"status"` + Data basemodel.FeatureSet `json:"data"` +} + +// MergeFeatureSets merges two FeatureSet arrays with precedence to zeusFeatures. +func MergeFeatureSets(zeusFeatures, internalFeatures basemodel.FeatureSet) basemodel.FeatureSet { + // Create a map to store the merged features + featureMap := make(map[string]basemodel.Feature) + + // Add all features from the otherFeatures set to the map + for _, feature := range internalFeatures { + featureMap[feature.Name] = feature + } + + // Add all features from the zeusFeatures set to the map + // If a feature already exists (i.e., same name), the zeusFeature will overwrite it + for _, feature := range zeusFeatures { + featureMap[feature.Name] = feature + } + + // Convert the map back to a FeatureSet slice + var mergedFeatures basemodel.FeatureSet + for _, feature := range featureMap { + mergedFeatures = append(mergedFeatures, feature) + } + + return mergedFeatures +} diff --git a/ee/query-service/app/api/featureFlags_test.go b/ee/query-service/app/api/featureFlags_test.go new file mode 100644 index 0000000000..99a7be1521 --- /dev/null +++ b/ee/query-service/app/api/featureFlags_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + basemodel "go.signoz.io/signoz/pkg/query-service/model" +) + +func TestMergeFeatureSets(t *testing.T) { + tests := []struct { + name string + zeusFeatures basemodel.FeatureSet + internalFeatures basemodel.FeatureSet + expected basemodel.FeatureSet + }{ + { + name: "empty zeusFeatures and internalFeatures", + zeusFeatures: basemodel.FeatureSet{}, + internalFeatures: basemodel.FeatureSet{}, + expected: basemodel.FeatureSet{}, + }, + { + name: "non-empty zeusFeatures and empty internalFeatures", + zeusFeatures: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + }, + internalFeatures: basemodel.FeatureSet{}, + expected: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + }, + }, + { + name: "empty zeusFeatures and non-empty internalFeatures", + zeusFeatures: basemodel.FeatureSet{}, + internalFeatures: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + }, + expected: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + }, + }, + { + name: "non-empty zeusFeatures and non-empty internalFeatures with no conflicts", + zeusFeatures: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature3", Active: false}, + }, + internalFeatures: basemodel.FeatureSet{ + {Name: "Feature2", Active: true}, + {Name: "Feature4", Active: false}, + }, + expected: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: true}, + {Name: "Feature3", Active: false}, + {Name: "Feature4", Active: false}, + }, + }, + { + name: "non-empty zeusFeatures and non-empty internalFeatures with conflicts", + zeusFeatures: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + }, + internalFeatures: basemodel.FeatureSet{ + {Name: "Feature1", Active: false}, + {Name: "Feature3", Active: true}, + }, + expected: basemodel.FeatureSet{ + {Name: "Feature1", Active: true}, + {Name: "Feature2", Active: false}, + {Name: "Feature3", Active: true}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := MergeFeatureSets(test.zeusFeatures, test.internalFeatures) + assert.ElementsMatch(t, test.expected, actual) + }) + } +} diff --git a/ee/query-service/app/db/metrics.go b/ee/query-service/app/db/metrics.go index 5a694edab6..0cc8a55c32 100644 --- a/ee/query-service/app/db/metrics.go +++ b/ee/query-service/app/db/metrics.go @@ -21,7 +21,7 @@ import ( // GetMetricResultEE runs the query and returns list of time series func (r *ClickhouseReader) GetMetricResultEE(ctx context.Context, query string) ([]*basemodel.Series, string, error) { - defer utils.Elapsed("GetMetricResult")() + defer utils.Elapsed("GetMetricResult", nil)() zap.L().Info("Executing metric result query: ", zap.String("query", query)) var hash string diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 75af1d7ebc..ee019e639a 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -1,14 +1,15 @@ package app import ( + "bufio" "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" "net/http" - "net/http/httputil" _ "net/http/pprof" // http profiler "os" "regexp" @@ -27,7 +28,10 @@ import ( "go.signoz.io/signoz/ee/query-service/dao" "go.signoz.io/signoz/ee/query-service/integrations/gateway" "go.signoz.io/signoz/ee/query-service/interfaces" + "go.signoz.io/signoz/ee/query-service/rules" baseauth "go.signoz.io/signoz/pkg/query-service/auth" + "go.signoz.io/signoz/pkg/query-service/migrate" + "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" licensepkg "go.signoz.io/signoz/ee/query-service/license" @@ -41,6 +45,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" "go.signoz.io/signoz/pkg/query-service/app/opamp" opAmpModel "go.signoz.io/signoz/pkg/query-service/app/opamp/model" + "go.signoz.io/signoz/pkg/query-service/app/preferences" "go.signoz.io/signoz/pkg/query-service/cache" baseconst "go.signoz.io/signoz/pkg/query-service/constants" "go.signoz.io/signoz/pkg/query-service/healthcheck" @@ -48,7 +53,7 @@ import ( baseint "go.signoz.io/signoz/pkg/query-service/interfaces" basemodel "go.signoz.io/signoz/pkg/query-service/model" pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine" - rules "go.signoz.io/signoz/pkg/query-service/rules" + baserules "go.signoz.io/signoz/pkg/query-service/rules" "go.signoz.io/signoz/pkg/query-service/telemetry" "go.signoz.io/signoz/pkg/query-service/utils" "go.uber.org/zap" @@ -64,7 +69,6 @@ type ServerOptions struct { // alert specific params DisableRules bool RuleRepoURL string - PreferDelta bool PreferSpanMetrics bool MaxIdleConns int MaxOpenConns int @@ -78,7 +82,7 @@ type ServerOptions struct { // Server runs HTTP api service type Server struct { serverOptions *ServerOptions - ruleManager *rules.Manager + ruleManager *baserules.Manager // public http router httpConn net.Listener @@ -111,6 +115,10 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { baseexplorer.InitWithDSN(baseconst.RELATIONAL_DATASOURCE_PATH) + if err := preferences.InitDB(baseconst.RELATIONAL_DATASOURCE_PATH); err != nil { + return nil, err + } + localDB, err := dashboards.InitDB(baseconst.RELATIONAL_DATASOURCE_PATH) if err != nil { @@ -119,33 +127,13 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { localDB.SetMaxOpenConns(10) - gatewayFeature := basemodel.Feature{ - Name: "GATEWAY", - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - } - - //Activate this feature if the url is not empty - var gatewayProxy *httputil.ReverseProxy - if serverOptions.GatewayUrl == "" { - gatewayFeature.Active = false - gatewayProxy, err = gateway.NewNoopProxy() - if err != nil { - return nil, err - } - } else { - zap.L().Info("Enabling gateway feature flag ...") - gatewayFeature.Active = true - gatewayProxy, err = gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix) - if err != nil { - return nil, err - } + gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix) + if err != nil { + return nil, err } // initiate license manager - lm, err := licensepkg.StartManager("sqlite", localDB, gatewayFeature) + lm, err := licensepkg.StartManager("sqlite", localDB) if err != nil { return nil, err } @@ -194,6 +182,13 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { return nil, err } + go func() { + err = migrate.ClickHouseMigrate(reader.GetConn(), serverOptions.Cluster) + if err != nil { + zap.L().Error("error while running clickhouse migrations", zap.Error(err)) + } + }() + // initiate opamp _, err = opAmpModel.InitDB(localDB) if err != nil { @@ -256,7 +251,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { apiOpts := api.APIHandlerOptions{ DataConnector: reader, SkipConfig: skipConfig, - PreferDelta: serverOptions.PreferDelta, PreferSpanMetrics: serverOptions.PreferSpanMetrics, MaxIdleConns: serverOptions.MaxIdleConns, MaxOpenConns: serverOptions.MaxOpenConns, @@ -325,7 +319,7 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, // ip here for alert manager AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"}, }) handler := c.Handler(r) @@ -342,7 +336,17 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e // add auth middleware getUserFromRequest := func(r *http.Request) (*basemodel.UserPayload, error) { - return auth.GetUserFromRequest(r, apiHandler) + user, err := auth.GetUserFromRequest(r, apiHandler) + + if err != nil { + return nil, err + } + + if user.User.OrgId == "" { + return nil, model.UnauthorizedError(errors.New("orgId is missing in the claims")) + } + + return user, nil } am := baseapp.NewAuthMiddleware(getUserFromRequest) @@ -356,11 +360,13 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e apiHandler.RegisterIntegrationRoutes(r, am) apiHandler.RegisterQueryRangeV3Routes(r, am) apiHandler.RegisterQueryRangeV4Routes(r, am) + apiHandler.RegisterWebSocketPaths(r, am) + apiHandler.RegisterMessagingQueuesRoutes(r, am) c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"}, }) handler := c.Handler(r) @@ -372,6 +378,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e }, nil } +// TODO(remove): Implemented at pkg/http/middleware/logging.go // loggingMiddleware is used for logging public api calls func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -383,6 +390,7 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } +// TODO(remove): Implemented at pkg/http/middleware/logging.go // loggingMiddlewarePrivate is used for logging private api calls // from internal services like alert manager func loggingMiddlewarePrivate(next http.Handler) http.Handler { @@ -395,27 +403,41 @@ func loggingMiddlewarePrivate(next http.Handler) http.Handler { }) } +// TODO(remove): Implemented at pkg/http/middleware/logging.go type loggingResponseWriter struct { http.ResponseWriter statusCode int } +// TODO(remove): Implemented at pkg/http/middleware/logging.go func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { // WriteHeader(int) is not called if our response implicitly returns 200 OK, so // we default to that status code. return &loggingResponseWriter{w, http.StatusOK} } +// TODO(remove): Implemented at pkg/http/middleware/logging.go func (lrw *loggingResponseWriter) WriteHeader(code int) { lrw.statusCode = code lrw.ResponseWriter.WriteHeader(code) } +// TODO(remove): Implemented at pkg/http/middleware/logging.go // Flush implements the http.Flush interface. func (lrw *loggingResponseWriter) Flush() { lrw.ResponseWriter.(http.Flusher).Flush() } +// TODO(remove): Implemented at pkg/http/middleware/logging.go +// Support websockets +func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := lrw.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("hijack not supported") + } + return h.Hijack() +} + func extractQueryRangeData(path string, r *http.Request) (map[string]interface{}, bool) { pathToExtractBodyFromV3 := "/api/v3/query_range" pathToExtractBodyFromV4 := "/api/v4/query_range" @@ -552,6 +574,7 @@ func (s *Server) analyticsMiddleware(next http.Handler) http.Handler { }) } +// TODO(remove): Implemented at pkg/http/middleware/timeout.go func setTimeoutMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -705,7 +728,7 @@ func makeRulesManager( db *sqlx.DB, ch baseint.Reader, disableRules bool, - fm baseint.FeatureLookup) (*rules.Manager, error) { + fm baseint.FeatureLookup) (*baserules.Manager, error) { // create engine pqle, err := pqle.FromConfigPath(promConfigPath) @@ -721,12 +744,9 @@ func makeRulesManager( } // create manager opts - managerOpts := &rules.ManagerOptions{ + managerOpts := &baserules.ManagerOptions{ NotifierOpts: notifierOpts, - Queriers: &rules.Queriers{ - PqlEngine: pqle, - Ch: ch.GetConn(), - }, + PqlEngine: pqle, RepoURL: ruleRepoURL, DBConn: db, Context: context.Background(), @@ -734,10 +754,13 @@ func makeRulesManager( DisableRules: disableRules, FeatureFlags: fm, Reader: ch, + EvalDelay: baseconst.GetEvalDelay(), + + PrepareTaskFunc: rules.PrepareTaskFunc, } // create Manager - manager, err := rules.NewManager(managerOpts) + manager, err := baserules.NewManager(managerOpts) if err != nil { return nil, fmt.Errorf("rule manager error: %v", err) } diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index cc4bb07476..c1baa6320b 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -11,8 +11,8 @@ const ( var LicenseSignozIo = "https://license.signoz.io/api/v1" var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") -var SpanRenderLimitStr = GetOrDefaultEnv("SPAN_RENDER_LIMIT", "2500") -var MaxSpansInTraceStr = GetOrDefaultEnv("MAX_SPANS_IN_TRACE", "250000") +var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") +var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") func GetOrDefaultEnv(key string, fallback string) string { v := os.Getenv(key) diff --git a/ee/query-service/dao/sqlite/auth.go b/ee/query-service/dao/sqlite/auth.go index 4418b04cbf..b8bc5e0fa0 100644 --- a/ee/query-service/dao/sqlite/auth.go +++ b/ee/query-service/dao/sqlite/auth.go @@ -20,11 +20,14 @@ import ( func (m *modelDao) createUserForSAMLRequest(ctx context.Context, email string) (*basemodel.User, basemodel.BaseApiError) { // get auth domain from email domain domain, apierr := m.GetDomainByEmail(ctx, email) - if apierr != nil { zap.L().Error("failed to get domain from email", zap.Error(apierr)) return nil, model.InternalErrorStr("failed to get domain from email") } + if domain == nil { + zap.L().Error("email domain does not match any authenticated domain", zap.String("email", email)) + return nil, model.InternalErrorStr("email domain does not match any authenticated domain") + } hash, err := baseauth.PasswordHash(utils.GeneratePassowrd()) if err != nil { diff --git a/ee/query-service/integrations/gateway/noop.go b/ee/query-service/integrations/gateway/noop.go index bbe930e2f9..ccb5d3269a 100644 --- a/ee/query-service/integrations/gateway/noop.go +++ b/ee/query-service/integrations/gateway/noop.go @@ -5,5 +5,5 @@ import ( ) func NewNoopProxy() (*httputil.ReverseProxy, error) { - return nil, nil + return &httputil.ReverseProxy{}, nil } diff --git a/ee/query-service/license/manager.go b/ee/query-service/license/manager.go index 74887608ab..800f4b7ff3 100644 --- a/ee/query-service/license/manager.go +++ b/ee/query-service/license/manager.go @@ -147,7 +147,7 @@ func (lm *Manager) GetLicenses(ctx context.Context) (response []model.License, a for _, l := range licenses { l.ParsePlan() - if l.Key == lm.activeLicense.Key { + if lm.activeLicense != nil && l.Key == lm.activeLicense.Key { l.IsCurrent = true } diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 4a8a12af6e..c5a03f4c0f 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -89,7 +89,6 @@ func main() { var cacheConfigPath, fluxInterval string var enableQueryServiceLogOTLPExport bool - var preferDelta bool var preferSpanMetrics bool var maxIdleConns int @@ -100,14 +99,13 @@ func main() { 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)") - flag.BoolVar(&preferDelta, "prefer-delta", false, "(prefer delta over cumulative metrics)") flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)") flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool.)") flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time.)") flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)") flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)") flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)") - flag.StringVar(&fluxInterval, "flux-interval", "5m", "(cache config to use)") + flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)") 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)") @@ -125,7 +123,6 @@ func main() { HTTPHostPort: baseconst.HTTPHostPort, PromConfigPath: promConfigPath, SkipTopLvlOpsPath: skipTopLvlOpsPath, - PreferDelta: preferDelta, PreferSpanMetrics: preferSpanMetrics, PrivateHostPort: baseconst.PrivateHostPort, DisableRules: disableRules, diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 41bca047d5..dbd8b56965 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -11,6 +11,9 @@ const Enterprise = "ENTERPRISE_PLAN" const DisableUpsell = "DISABLE_UPSELL" const Onboarding = "ONBOARDING" const ChatSupport = "CHAT_SUPPORT" +const Gateway = "GATEWAY" +const PremiumSupport = "PREMIUM_SUPPORT" +const QueryBuilderSearchV2 = "QUERY_BUILDER_SEARCH_V2" var BasicPlan = basemodel.FeatureSet{ basemodel.Feature{ @@ -111,6 +114,27 @@ var BasicPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: Gateway, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: PremiumSupport, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } var ProPlan = basemodel.FeatureSet{ @@ -205,6 +229,27 @@ var ProPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: Gateway, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: PremiumSupport, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } var EnterprisePlan = basemodel.FeatureSet{ @@ -313,4 +358,25 @@ var EnterprisePlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: Gateway, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: PremiumSupport, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go new file mode 100644 index 0000000000..d3bc03f58a --- /dev/null +++ b/ee/query-service/rules/manager.go @@ -0,0 +1,69 @@ +package rules + +import ( + "fmt" + "time" + + baserules "go.signoz.io/signoz/pkg/query-service/rules" +) + +func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) { + + rules := make([]baserules.Rule, 0) + var task baserules.Task + + ruleId := baserules.RuleIdFromTaskName(opts.TaskName) + if opts.Rule.RuleType == baserules.RuleTypeThreshold { + // create a threshold rule + tr, err := baserules.NewThresholdRule( + ruleId, + opts.Rule, + opts.FF, + opts.Reader, + baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay), + ) + + if err != nil { + return task, err + } + + rules = append(rules, tr) + + // create ch rule task for evalution + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else if opts.Rule.RuleType == baserules.RuleTypeProm { + + // create promql rule + pr, err := baserules.NewPromRule( + ruleId, + opts.Rule, + opts.Logger, + opts.Reader, + opts.ManagerOpts.PqlEngine, + ) + + if err != nil { + return task, err + } + + rules = append(rules, pr) + + // create promql rule task for evalution + task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else { + return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", baserules.RuleTypeProm, baserules.RuleTypeThreshold) + } + + return task, nil +} + +// newTask returns an appropriate group for +// rule type +func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, ruleDB baserules.RuleDB) baserules.Task { + if taskType == baserules.TaskTypeCh { + return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, ruleDB) + } + return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, ruleDB) +} diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index d7776b0034..122b309dae 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -9,6 +9,7 @@ const config: Config.InitialOptions = { modulePathIgnorePatterns: ['dist'], moduleNameMapper: { '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', + '\\.md$': '/__mocks__/cssMock.ts', }, globals: { extensionsToTreatAsEsm: ['.ts'], diff --git a/frontend/package.json b/frontend/package.json index 25d32f69df..51097f7696 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "ansi-to-html": "0.7.2", "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", - "axios": "1.6.4", + "axios": "1.7.4", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", "babel-loader": "9.1.3", @@ -88,6 +88,7 @@ "lucide-react": "0.379.0", "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", + "posthog-js": "1.160.3", "rc-tween-one": "3.0.6", "react": "18.2.0", "react-addons-update": "15.6.3", @@ -109,6 +110,8 @@ "react-syntax-highlighter": "15.5.0", "react-use": "^17.3.2", "react-virtuoso": "4.0.3", + "overlayscrollbars-react": "^0.5.6", + "overlayscrollbars": "^2.8.1", "redux": "^4.0.5", "redux-thunk": "^2.3.0", "rehype-raw": "7.0.0", diff --git a/frontend/public/Icons/groupBy.svg b/frontend/public/Icons/groupBy.svg new file mode 100644 index 0000000000..e668ef176a --- /dev/null +++ b/frontend/public/Icons/groupBy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Icons/solid-x-circle.svg b/frontend/public/Icons/solid-x-circle.svg new file mode 100644 index 0000000000..3f189e3865 --- /dev/null +++ b/frontend/public/Icons/solid-x-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-aks.svg b/frontend/public/Logos/azure-aks.svg new file mode 100644 index 0000000000..d45672703d --- /dev/null +++ b/frontend/public/Logos/azure-aks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-app-service.svg b/frontend/public/Logos/azure-app-service.svg new file mode 100644 index 0000000000..54051fc58f --- /dev/null +++ b/frontend/public/Logos/azure-app-service.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-blob-storage.svg b/frontend/public/Logos/azure-blob-storage.svg new file mode 100644 index 0000000000..1650133096 --- /dev/null +++ b/frontend/public/Logos/azure-blob-storage.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/public/Logos/azure-container-apps.svg b/frontend/public/Logos/azure-container-apps.svg new file mode 100644 index 0000000000..3dd3d4db91 --- /dev/null +++ b/frontend/public/Logos/azure-container-apps.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-functions.svg b/frontend/public/Logos/azure-functions.svg new file mode 100644 index 0000000000..9face30fb9 --- /dev/null +++ b/frontend/public/Logos/azure-functions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-sql-database-metrics.svg b/frontend/public/Logos/azure-sql-database-metrics.svg new file mode 100644 index 0000000000..fed69970bb --- /dev/null +++ b/frontend/public/Logos/azure-sql-database-metrics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/azure-vm.svg b/frontend/public/Logos/azure-vm.svg new file mode 100644 index 0000000000..bde2b81881 --- /dev/null +++ b/frontend/public/Logos/azure-vm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/fonts/GeistMonoVF.woff2 b/frontend/public/fonts/GeistMonoVF.woff2 new file mode 100644 index 0000000000..fb2f024aca Binary files /dev/null and b/frontend/public/fonts/GeistMonoVF.woff2 differ diff --git a/frontend/public/locales/en-GB/messagingQueuesKafkaOverview.json b/frontend/public/locales/en-GB/messagingQueuesKafkaOverview.json new file mode 100644 index 0000000000..5061a5ddcb --- /dev/null +++ b/frontend/public/locales/en-GB/messagingQueuesKafkaOverview.json @@ -0,0 +1,30 @@ +{ + "breadcrumb": "Messaging Queues", + "header": "Kafka / Overview", + "overview": { + "title": "Start sending data in as little as 20 minutes", + "subtitle": "Connect and Monitor Your Data Streams" + }, + "configureConsumer": { + "title": "Configure Consumer", + "description": "Add consumer data sources to gain insights and enhance monitoring.", + "button": "Get Started" + }, + "configureProducer": { + "title": "Configure Producer", + "description": "Add producer data sources to gain insights and enhance monitoring.", + "button": "Get Started" + }, + "monitorKafka": { + "title": "Monitor kafka", + "description": "Add your Kafka source to gain insights and enhance activity tracking.", + "button": "Get Started" + }, + "summarySection": { + "viewDetailsButton": "View Details" + }, + "confirmModal": { + "content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.", + "okText": "Proceed" + } +} \ No newline at end of file diff --git a/frontend/public/locales/en-GB/onboarding.json b/frontend/public/locales/en-GB/onboarding.json new file mode 100644 index 0000000000..573282687e --- /dev/null +++ b/frontend/public/locales/en-GB/onboarding.json @@ -0,0 +1,8 @@ +{ + "invite_user": "Invite your teammates", + "invite": "Invite", + "skip": "Skip", + "invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.", + "select_use_case": "Select a use-case to get started", + "get_started": "Get Started" +} diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 0eb98e9960..6cfe6e0238 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -38,5 +38,7 @@ "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "SUPPORT": "SigNoz | Support", - "DEFAULT": "Open source Observability Platform | SigNoz" + "DEFAULT": "Open source Observability Platform | SigNoz", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview" } diff --git a/frontend/public/locales/en-GB/workspaceLocked.json b/frontend/public/locales/en-GB/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en-GB/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade 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": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f167aecffc..72d9f13810 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -6,5 +6,6 @@ "share": "Share", "save": "Save", "edit": "Edit", - "logged_in": "Logged In" + "logged_in": "Logged In", + "pending_data_placeholder": "Just a bit of patience, just a little bit’s enough ⎯ we’re getting your {{dataSource}}!" } diff --git a/frontend/public/locales/en/dashboard.json b/frontend/public/locales/en/dashboard.json index d2e90237a9..26e8f289a9 100644 --- a/frontend/public/locales/en/dashboard.json +++ b/frontend/public/locales/en/dashboard.json @@ -1,6 +1,7 @@ { "create_dashboard": "Create Dashboard", "import_json": "Import Dashboard JSON", + "view_template": "View templates", "import_grafana_json": "Import Grafana JSON", "copy_to_clipboard": "Copy To ClipBoard", "download_json": "Download JSON", diff --git a/frontend/public/locales/en/messagingQueuesKafkaOverview.json b/frontend/public/locales/en/messagingQueuesKafkaOverview.json new file mode 100644 index 0000000000..5061a5ddcb --- /dev/null +++ b/frontend/public/locales/en/messagingQueuesKafkaOverview.json @@ -0,0 +1,30 @@ +{ + "breadcrumb": "Messaging Queues", + "header": "Kafka / Overview", + "overview": { + "title": "Start sending data in as little as 20 minutes", + "subtitle": "Connect and Monitor Your Data Streams" + }, + "configureConsumer": { + "title": "Configure Consumer", + "description": "Add consumer data sources to gain insights and enhance monitoring.", + "button": "Get Started" + }, + "configureProducer": { + "title": "Configure Producer", + "description": "Add producer data sources to gain insights and enhance monitoring.", + "button": "Get Started" + }, + "monitorKafka": { + "title": "Monitor kafka", + "description": "Add your Kafka source to gain insights and enhance activity tracking.", + "button": "Get Started" + }, + "summarySection": { + "viewDetailsButton": "View Details" + }, + "confirmModal": { + "content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.", + "okText": "Proceed" + } +} \ No newline at end of file diff --git a/frontend/public/locales/en/onboarding.json b/frontend/public/locales/en/onboarding.json new file mode 100644 index 0000000000..573282687e --- /dev/null +++ b/frontend/public/locales/en/onboarding.json @@ -0,0 +1,8 @@ +{ + "invite_user": "Invite your teammates", + "invite": "Invite", + "skip": "Skip", + "invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.", + "select_use_case": "Select a use-case to get started", + "get_started": "Get Started" +} diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 8aef9c9af6..126b8a7ac1 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -8,6 +8,7 @@ "GET_STARTED_LOGS_MANAGEMENT": "SigNoz | Get Started | Logs", "GET_STARTED_INFRASTRUCTURE_MONITORING": "SigNoz | Get Started | Infrastructure", "GET_STARTED_AWS_MONITORING": "SigNoz | Get Started | AWS", + "GET_STARTED_AZURE_MONITORING": "SigNoz | Get Started | AZURE", "TRACE": "SigNoz | Trace", "TRACE_DETAIL": "SigNoz | Trace Detail", "TRACES_EXPLORER": "SigNoz | Traces Explorer", @@ -48,5 +49,8 @@ "TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views", "DEFAULT": "Open source Observability Platform | SigNoz", "SHORTCUTS": "SigNoz | Shortcuts", - "INTEGRATIONS": "SigNoz | Integrations" + "INTEGRATIONS": "SigNoz | Integrations", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", + "MESSAGING_QUEUES": "SigNoz | Messaging Queues" } diff --git a/frontend/public/locales/en/workspaceLocked.json b/frontend/public/locales/en/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade 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": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index 669def6f44..43402fdbb2 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -76,9 +76,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { isUserFetching: false, }, }); - if (!isLoggedIn) { - history.push(ROUTES.LOGIN); + history.push(ROUTES.LOGIN, { from: pathname }); } }; diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 645974204c..b900255172 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -1,6 +1,7 @@ import { ConfigProvider } from 'antd'; import getLocalStorageApi from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; +import logEvent from 'api/common/logEvent'; import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; import { FeatureKeys } from 'constants/features'; @@ -17,6 +18,8 @@ import { NotificationProvider } from 'hooks/useNotifications'; import { ResourceProvider } from 'hooks/useResourceAttribute'; import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; +import posthog from 'posthog-js'; +import AlertRuleProvider from 'providers/Alert'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -38,7 +41,7 @@ import defaultRoutes, { function App(): JSX.Element { const themeConfig = useThemeConfig(); - const { data } = useLicense(); + const { data: licenseData } = useLicense(); const [routes, setRoutes] = useState(defaultRoutes); const { role, isLoggedIn: isLoggedInState, user, org } = useSelector< AppState, @@ -47,7 +50,7 @@ function App(): JSX.Element { const dispatch = useDispatch>(); - const { trackPageView, trackEvent } = useAnalytics(); + const { trackPageView } = useAnalytics(); const { hostname, pathname } = window.location; @@ -64,6 +67,14 @@ function App(): JSX.Element { allFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)?.active || false; + const isPremiumSupportEnabled = + allFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)?.active || + false; + + const showAddCreditCardModal = + !isPremiumSupportEnabled && + !licenseData?.payload?.trialConvertedToSubscription; + dispatch({ type: UPDATE_FEATURE_FLAG_RESPONSE, payload: { @@ -80,7 +91,7 @@ function App(): JSX.Element { setRoutes(newRoutes); } - if (isLoggedInState && isChatSupportEnabled) { + if (isLoggedInState && isChatSupportEnabled && !showAddCreditCardModal) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore window.Intercom('boot', { @@ -92,10 +103,10 @@ function App(): JSX.Element { }); const isOnBasicPlan = - data?.payload?.licenses?.some( + licenseData?.payload?.licenses?.some( (license) => license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN, - ) || data?.payload?.licenses === null; + ) || licenseData?.payload?.licenses === null; const enableAnalytics = (user: User): void => { const orgName = @@ -112,9 +123,7 @@ function App(): JSX.Element { }; const sanitizedIdentifyPayload = pickBy(identifyPayload, identity); - const domain = extractDomain(email); - const hostNameParts = hostname.split('.'); const groupTraits = { @@ -127,10 +136,30 @@ function App(): JSX.Element { }; window.analytics.identify(email, sanitizedIdentifyPayload); - window.analytics.group(domain, groupTraits); - window.clarity('identify', email, name); + + posthog?.identify(email, { + email, + name, + orgName, + tenant_id: hostNameParts[0], + data_region: hostNameParts[1], + tenant_url: hostname, + company_domain: domain, + source: 'signoz-ui', + isPaidUser: !!licenseData?.payload?.trialConvertedToSubscription, + }); + + posthog?.group('company', domain, { + name: orgName, + tenant_id: hostNameParts[0], + data_region: hostNameParts[1], + tenant_url: hostname, + company_domain: domain, + source: 'signoz-ui', + isPaidUser: !!licenseData?.payload?.trialConvertedToSubscription, + }); }; useEffect(() => { @@ -144,10 +173,6 @@ function App(): JSX.Element { !isIdentifiedUser ) { setLocalStorageApi(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true'); - - if (isCloudUserVal) { - enableAnalytics(user); - } } if ( @@ -184,7 +209,7 @@ function App(): JSX.Element { LOCALSTORAGE.THEME_ANALYTICS_V1, ); if (!isThemeAnalyticsSent) { - trackEvent('Theme Analytics', { + logEvent('Theme Analytics', { theme: isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT, user: pick(user, ['email', 'userId', 'name']), org, @@ -195,6 +220,11 @@ function App(): JSX.Element { console.error('Failed to parse local storage theme analytics event'); } } + + if (isCloudUserVal && user && user.email) { + enableAnalytics(user); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); @@ -207,22 +237,24 @@ function App(): JSX.Element { - - }> - - {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 9275e7d6f6..0a7764149b 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable( () => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'), ); +export const AlertHistory = Loadable( + () => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'), +); + +export const AlertOverview = Loadable( + () => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'), +); + export const CreateAlertChannelAlerts = Loadable( () => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'), @@ -204,3 +212,15 @@ export const InstalledIntegrations = Loadable( /* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage' ), ); + +export const MessagingQueues = Loadable( + () => + import(/* webpackChunkName: "MessagingQueues" */ 'pages/MessagingQueues'), +); + +export const MQDetailPage = Loadable( + () => + import( + /* webpackChunkName: "MQDetailPage" */ 'pages/MessagingQueues/MQDetailPage' + ), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 4fd421ffba..42ce00c0fb 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -2,6 +2,8 @@ import ROUTES from 'constants/routes'; import { RouteProps } from 'react-router-dom'; import { + AlertHistory, + AlertOverview, AllAlertChannels, AllErrors, APIKeys, @@ -23,6 +25,8 @@ import { LogsExplorer, LogsIndexToFields, LogsSaveViews, + MessagingQueues, + MQDetailPage, MySettings, NewDashboardPage, OldLogsExplorer, @@ -169,6 +173,20 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'ALERTS_NEW', }, + { + path: ROUTES.ALERT_HISTORY, + exact: true, + component: AlertHistory, + isPrivate: true, + key: 'ALERT_HISTORY', + }, + { + path: ROUTES.ALERT_OVERVIEW, + exact: true, + component: AlertOverview, + isPrivate: true, + key: 'ALERT_OVERVIEW', + }, { path: ROUTES.TRACE, exact: true, @@ -351,6 +369,20 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'INTEGRATIONS', }, + { + path: ROUTES.MESSAGING_QUEUES, + exact: true, + component: MessagingQueues, + key: 'MESSAGING_QUEUES', + isPrivate: true, + }, + { + path: ROUTES.MESSAGING_QUEUES_DETAIL, + exact: true, + component: MQDetailPage, + key: 'MESSAGING_QUEUES_DETAIL', + isPrivate: true, + }, ]; export const SUPPORT_ROUTE: AppRoutes = { diff --git a/frontend/src/api/ErrorResponseHandler.ts b/frontend/src/api/ErrorResponseHandler.ts index be2dd5e31a..6d972ec90f 100644 --- a/frontend/src/api/ErrorResponseHandler.ts +++ b/frontend/src/api/ErrorResponseHandler.ts @@ -9,9 +9,9 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse { // making the error status code as standard Error Status Code const statusCode = response.status as ErrorStatusCode; - if (statusCode >= 400 && statusCode < 500) { - const { data } = response as AxiosResponse; + const { data } = response as AxiosResponse; + if (statusCode >= 400 && statusCode < 500) { if (statusCode === 404) { return { statusCode, @@ -34,12 +34,11 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse { body: JSON.stringify((response.data as any).data), }; } - return { statusCode, payload: null, error: 'Something went wrong', - message: null, + message: data?.error, }; } if (request) { diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index cad7917815..744183fa4b 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/create'; const create = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.post('/rules', { - ...props.data, - }); + const response = await axios.post('/rules', { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default create; diff --git a/frontend/src/api/alerts/delete.ts b/frontend/src/api/alerts/delete.ts index 278e3e2935..56407f3c40 100644 --- a/frontend/src/api/alerts/delete.ts +++ b/frontend/src/api/alerts/delete.ts @@ -1,24 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/delete'; const deleteAlerts = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/rules/${props.id}`); + const response = await axios.delete(`/rules/${props.id}`); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data.rules, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.rules, + }; }; export default deleteAlerts; diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index 0437f8d1d8..15a741287e 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -1,24 +1,16 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/get'; const get = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/rules/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + const response = await axios.get(`/rules/${props.id}`); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; }; - export default get; diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 920b53ae9f..cb64a1046f 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/patch'; const patch = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.patch(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.patch(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default patch; diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index b8c34e96bd..77d98d3c49 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.put(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default put; diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2e09751e0f --- /dev/null +++ b/frontend/src/api/alerts/ruleStats.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; +import { RuleStatsProps } from 'types/api/alerts/ruleStats'; + +const ruleStats = async ( + props: RuleStatsProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/rules/${props.id}/history/stats`, { + start: props.start, + end: props.end, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default ruleStats; diff --git a/frontend/src/api/alerts/timelineGraph.ts b/frontend/src/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..8073943d72 --- /dev/null +++ b/frontend/src/api/alerts/timelineGraph.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph'; + +const timelineGraph = async ( + props: GetTimelineGraphRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/overall_status`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineGraph; diff --git a/frontend/src/api/alerts/timelineTable.ts b/frontend/src/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..8d7f3edee7 --- /dev/null +++ b/frontend/src/api/alerts/timelineTable.ts @@ -0,0 +1,36 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable'; + +const timelineTable = async ( + props: GetTimelineTableRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post(`/rules/${props.id}/history/timeline`, { + start: props.start, + end: props.end, + offset: props.offset, + limit: props.limit, + order: props.order, + state: props.state, + // TODO(shaheer): implement filters + filters: props.filters, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineTable; diff --git a/frontend/src/api/alerts/topContributors.ts b/frontend/src/api/alerts/topContributors.ts new file mode 100644 index 0000000000..7d3f2baec1 --- /dev/null +++ b/frontend/src/api/alerts/topContributors.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def'; +import { TopContributorsProps } from 'types/api/alerts/topContributors'; + +const topContributors = async ( + props: TopContributorsProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/top_contributors`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default topContributors; diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index 05b4e62e78..613ed27a17 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -3,7 +3,7 @@ const apiV1 = '/api/v1/'; export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; export const apiV4 = '/api/v4/'; -export const gatewayApiV1 = '/api/gateway/v1'; -export const apiAlertManager = '/api/alertmanager'; +export const gatewayApiV1 = '/api/gateway/v1/'; +export const apiAlertManager = '/api/alertmanager/'; export default apiV1; diff --git a/frontend/src/api/common/getQueryStats.ts b/frontend/src/api/common/getQueryStats.ts new file mode 100644 index 0000000000..c7e8bd2b4b --- /dev/null +++ b/frontend/src/api/common/getQueryStats.ts @@ -0,0 +1,62 @@ +import getLocalStorageApi from 'api/browser/localstorage/get'; +import { ENVIRONMENT } from 'constants/env'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { isEmpty } from 'lodash-es'; + +export interface WsDataEvent { + read_rows: number; + read_bytes: number; + elapsed_ms: number; +} +interface GetQueryStatsProps { + queryId: string; + setData: React.Dispatch>; +} + +function getURL(baseURL: string, queryId: string): URL | string { + if (baseURL && !isEmpty(baseURL)) { + return `${baseURL}/ws/query_progress?q=${queryId}`; + } + const url = new URL(`/ws/query_progress?q=${queryId}`, window.location.href); + + if (window.location.protocol === 'http:') { + url.protocol = 'ws'; + } else { + url.protocol = 'wss'; + } + + return url; +} + +export function getQueryStats(props: GetQueryStatsProps): void { + const { queryId, setData } = props; + + const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || ''; + + // https://github.com/whatwg/websockets/issues/20 reason for not using the relative URLs + const url = getURL(ENVIRONMENT.wsURL, queryId); + + const socket = new WebSocket(url, token); + + socket.addEventListener('message', (event) => { + try { + const parsedData = JSON.parse(event?.data); + setData(parsedData); + } catch { + setData(event?.data); + } + }); + + socket.addEventListener('error', (event) => { + console.error(event); + }); + + socket.addEventListener('close', (event) => { + // 1000 is a normal closure status code + if (event.code !== 1000) { + console.error('WebSocket closed with error:', event); + } else { + console.error('WebSocket closed normally.'); + } + }); +} diff --git a/frontend/src/api/common/logEvent.ts b/frontend/src/api/common/logEvent.ts index 212d382d77..a1bf3dba7c 100644 --- a/frontend/src/api/common/logEvent.ts +++ b/frontend/src/api/common/logEvent.ts @@ -1,4 +1,4 @@ -import axios from 'api'; +import { ApiBaseInstance as axios } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; @@ -21,6 +21,7 @@ const logEvent = async ( payload: response.data.data, }; } catch (error) { + console.error(error); return ErrorResponseHandler(error as AxiosError); } }; diff --git a/frontend/src/api/dashboard/variables/dashboardVariablesQuery.ts b/frontend/src/api/dashboard/variables/dashboardVariablesQuery.ts index 8605ce75f1..5e5c333520 100644 --- a/frontend/src/api/dashboard/variables/dashboardVariablesQuery.ts +++ b/frontend/src/api/dashboard/variables/dashboardVariablesQuery.ts @@ -1,6 +1,8 @@ import { ApiV2Instance as axios } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; +import store from 'store'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { Props, @@ -11,7 +13,26 @@ const dashboardVariablesQuery = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.post(`/variables/query`, props); + const { globalTime } = store.getState(); + const { start, end } = getStartEndRangeTime({ + type: 'GLOBAL_TIME', + interval: globalTime.selectedTime, + }); + + const timeVariables: Record = { + start_timestamp_ms: parseInt(start, 10) * 1e3, + end_timestamp_ms: parseInt(end, 10) * 1e3, + start_timestamp_nano: parseInt(start, 10) * 1e9, + end_timestamp_nano: parseInt(end, 10) * 1e9, + start_timestamp: parseInt(start, 10), + end_timestamp: parseInt(end, 10), + }; + + const payload = { ...props }; + + payload.variables = { ...payload.variables, ...timeVariables }; + + const response = await axios.post(`/variables/query`, payload); return { statusCode: 200, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1ec4cda601..7f5e2d476c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -96,6 +96,10 @@ const interceptorRejected = async ( } }; +const interceptorRejectedBase = async ( + value: AxiosResponse, +): Promise> => Promise.reject(value); + const instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, }); @@ -140,6 +144,18 @@ ApiV4Instance.interceptors.response.use( ApiV4Instance.interceptors.request.use(interceptorsRequestResponse); // +// axios Base +export const ApiBaseInstance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, +}); + +ApiBaseInstance.interceptors.response.use( + interceptorsResponse, + interceptorRejectedBase, +); +ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse); +// + // gateway Api V1 export const GatewayApiV1Instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${gatewayApiV1}`, diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index 40deb021bc..631372478d 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -12,10 +12,13 @@ export const getMetricsQueryRange = async ( props: QueryRangePayload, version: string, signal: AbortSignal, + headers?: Record, ): Promise | ErrorResponse> => { try { if (version && version === ENTITY_VERSION_V4) { - const response = await ApiV4Instance.post('/query_range', props, { signal }); + const response = await ApiV4Instance.post('/query_range', props, { + signal, + }); return { statusCode: 200, @@ -26,7 +29,10 @@ export const getMetricsQueryRange = async ( }; } - const response = await ApiV3Instance.post('/query_range', props, { signal }); + const response = await ApiV3Instance.post('/query_range', props, { + signal, + headers, + }); return { statusCode: 200, diff --git a/frontend/src/api/metrics/getTopLevelOperations.ts b/frontend/src/api/metrics/getTopLevelOperations.ts index 2f5a2026b2..a0f996412b 100644 --- a/frontend/src/api/metrics/getTopLevelOperations.ts +++ b/frontend/src/api/metrics/getTopLevelOperations.ts @@ -1,7 +1,20 @@ import axios from 'api'; +import { isNil } from 'lodash-es'; -const getTopLevelOperations = async (): Promise => { - const response = await axios.post(`/service/top_level_operations`); +interface GetTopLevelOperationsProps { + service?: string; + start?: number; + end?: number; +} + +const getTopLevelOperations = async ( + props: GetTopLevelOperationsProps, +): Promise => { + const response = await axios.post(`/service/top_level_operations`, { + start: !isNil(props.start) ? `${props.start}` : undefined, + end: !isNil(props.end) ? `${props.end}` : undefined, + service: props.service, + }); return response.data; }; diff --git a/frontend/src/api/plannedDowntime/createDowntimeSchedule.ts b/frontend/src/api/plannedDowntime/createDowntimeSchedule.ts index 128fb9bf69..26970264cf 100644 --- a/frontend/src/api/plannedDowntime/createDowntimeSchedule.ts +++ b/frontend/src/api/plannedDowntime/createDowntimeSchedule.ts @@ -1,6 +1,7 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; +import { Dayjs } from 'dayjs'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { Recurrence } from './getAllDowntimeSchedules'; @@ -11,8 +12,8 @@ export interface DowntimeSchedulePayload { alertIds: string[]; schedule: { timezone?: string; - startTime?: string; - endTime?: string; + startTime?: string | Dayjs; + endTime?: string | Dayjs; recurrence?: Recurrence; }; } diff --git a/frontend/src/api/plannedDowntime/getAllDowntimeSchedules.ts b/frontend/src/api/plannedDowntime/getAllDowntimeSchedules.ts index 8e77606a3f..d323c63a19 100644 --- a/frontend/src/api/plannedDowntime/getAllDowntimeSchedules.ts +++ b/frontend/src/api/plannedDowntime/getAllDowntimeSchedules.ts @@ -1,6 +1,6 @@ import axios from 'api'; import { AxiosError, AxiosResponse } from 'axios'; -import { Option } from 'container/PlannedDowntime/DropdownWithSubMenu/DropdownWithSubMenu'; +import { Option } from 'container/PlannedDowntime/PlannedDowntimeutils'; import { useQuery, UseQueryResult } from 'react-query'; export type Recurrence = { @@ -28,6 +28,7 @@ export interface DowntimeSchedules { createdBy: string | null; updatedAt: string | null; updatedBy: string | null; + kind: string | null; } export type PayloadProps = { data: DowntimeSchedules[] }; diff --git a/frontend/src/api/queryBuilder/getAttributeSuggestions.ts b/frontend/src/api/queryBuilder/getAttributeSuggestions.ts new file mode 100644 index 0000000000..45b380f9e8 --- /dev/null +++ b/frontend/src/api/queryBuilder/getAttributeSuggestions.ts @@ -0,0 +1,63 @@ +import { ApiV3Instance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError, AxiosResponse } from 'axios'; +import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder'; +import { encode } from 'js-base64'; +import { createIdFromObjectFields } from 'lib/createIdFromObjectFields'; +import createQueryParams from 'lib/createQueryParams'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + IGetAttributeSuggestionsPayload, + IGetAttributeSuggestionsSuccessResponse, +} from 'types/api/queryBuilder/getAttributeSuggestions'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +export const getAttributeSuggestions = async ({ + searchText, + dataSource, + filters, +}: IGetAttributeSuggestionsPayload): Promise< + SuccessResponse | ErrorResponse +> => { + try { + let base64EncodedFiltersString; + try { + // the replace function is to remove the padding at the end of base64 encoded string which is auto added to make it a multiple of 4 + // why ? because the current working of qs doesn't work well with padding + base64EncodedFiltersString = encode(JSON.stringify(filters)).replace( + /=+$/, + '', + ); + } catch { + // default base64 encoded string for empty filters object + base64EncodedFiltersString = 'eyJpdGVtcyI6W10sIm9wIjoiQU5EIn0'; + } + const response: AxiosResponse<{ + data: IGetAttributeSuggestionsSuccessResponse; + }> = await ApiV3Instance.get( + `/filter_suggestions?${createQueryParams({ + searchText, + dataSource, + existingFilter: base64EncodedFiltersString, + })}`, + ); + + const payload: BaseAutocompleteData[] = + response.data.data.attributes?.map(({ id: _, ...item }) => ({ + ...item, + id: createIdFromObjectFields(item, baseAutoCompleteIdKeysOrder), + })) || []; + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: { + attributes: payload, + example_queries: response.data.data.example_queries, + }, + }; + } catch (e) { + return ErrorResponseHandler(e as AxiosError); + } +}; diff --git a/frontend/src/assets/AlertHistory/ConfigureIcon.tsx b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx new file mode 100644 index 0000000000..05268b8f5f --- /dev/null +++ b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx @@ -0,0 +1,41 @@ +interface ConfigureIconProps { + width?: number; + height?: number; + fill?: string; +} + +function ConfigureIcon({ + width, + height, + fill, +}: ConfigureIconProps): JSX.Element { + return ( + + + + + ); +} + +ConfigureIcon.defaultProps = { + width: 16, + height: 16, + fill: 'none', +}; +export default ConfigureIcon; diff --git a/frontend/src/assets/AlertHistory/LogsIcon.tsx b/frontend/src/assets/AlertHistory/LogsIcon.tsx new file mode 100644 index 0000000000..8ffcaaa90b --- /dev/null +++ b/frontend/src/assets/AlertHistory/LogsIcon.tsx @@ -0,0 +1,65 @@ +interface LogsIconProps { + width?: number; + height?: number; + fill?: string; + strokeColor?: string; + strokeWidth?: number; +} + +function LogsIcon({ + width, + height, + fill, + strokeColor, + strokeWidth, +}: LogsIconProps): JSX.Element { + return ( + + + + + + + + + ); +} + +LogsIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + strokeColor: '#C0C1C3', + strokeWidth: 1.167, +}; + +export default LogsIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx new file mode 100644 index 0000000000..67d0977fe8 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx @@ -0,0 +1,39 @@ +interface SeverityCriticalIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityCriticalIcon({ + width, + height, + fill, + stroke, +}: SeverityCriticalIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityCriticalIcon.defaultProps = { + width: 6, + height: 6, + fill: 'none', + stroke: '#F56C87', +}; + +export default SeverityCriticalIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx new file mode 100644 index 0000000000..a402289a62 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityErrorIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityErrorIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityErrorIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityErrorIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#F56C87', + strokeWidth: '1.02083', +}; + +export default SeverityErrorIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx new file mode 100644 index 0000000000..72316b2244 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx @@ -0,0 +1,46 @@ +interface SeverityInfoIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityInfoIcon({ + width, + height, + fill, + stroke, +}: SeverityInfoIconProps): JSX.Element { + return ( + + + + + ); +} + +SeverityInfoIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + stroke: '#7190F9', +}; + +export default SeverityInfoIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx new file mode 100644 index 0000000000..204d615a21 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityWarningIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityWarningIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityWarningIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityWarningIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#FFD778', + strokeWidth: '0.978299', +}; + +export default SeverityWarningIcon; diff --git a/frontend/src/assets/CustomIcons/GroupByIcon.tsx b/frontend/src/assets/CustomIcons/GroupByIcon.tsx new file mode 100644 index 0000000000..4cfceef3c8 --- /dev/null +++ b/frontend/src/assets/CustomIcons/GroupByIcon.tsx @@ -0,0 +1,27 @@ +import { Color } from '@signozhq/design-tokens'; +import { useIsDarkMode } from 'hooks/useDarkMode'; + +function GroupByIcon(): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + + + + + + + + + + + + ); +} + +export default GroupByIcon; diff --git a/frontend/src/components/AlertDetailsFilters/Filters.styles.scss b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss new file mode 100644 index 0000000000..6869dd4366 --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss @@ -0,0 +1,14 @@ +.reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); +} + +.lightMode { + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } +} diff --git a/frontend/src/components/AlertDetailsFilters/Filters.tsx b/frontend/src/components/AlertDetailsFilters/Filters.tsx new file mode 100644 index 0000000000..baf109bf1d --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.tsx @@ -0,0 +1,11 @@ +import './Filters.styles.scss'; + +import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; + +export function Filters(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx new file mode 100644 index 0000000000..67353f8ba2 --- /dev/null +++ b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx @@ -0,0 +1,137 @@ +import { Button, Modal, Typography } from 'antd'; +import updateCreditCardApi from 'api/billing/checkout'; +import logEvent from 'api/common/logEvent'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import useLicense from 'hooks/useLicense'; +import { useNotifications } from 'hooks/useNotifications'; +import { CreditCard, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; +import { License } from 'types/api/licenses/def'; + +export default function ChatSupportGateway(): JSX.Element { + const { notifications } = useNotifications(); + const [activeLicense, setActiveLicense] = useState(null); + + const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] = useState( + false, + ); + + const { data: licenseData, isFetching } = useLicense(); + + useEffect(() => { + const activeValidLicense = + licenseData?.payload?.licenses?.find( + (license) => license.isCurrent === true, + ) || null; + + setActiveLicense(activeValidLicense); + }, [licenseData, isFetching]); + + const handleBillingOnSuccess = ( + data: ErrorResponse | SuccessResponse, + ): void => { + if (data?.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }; + + const handleBillingOnError = (): void => { + notifications.error({ + message: SOMETHING_WENT_WRONG, + }); + }; + + const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation( + updateCreditCardApi, + { + onSuccess: (data) => { + handleBillingOnSuccess(data); + }, + onError: handleBillingOnError, + }, + ); + const { pathname } = useLocation(); + const handleAddCreditCard = (): void => { + logEvent('Add Credit card modal: Clicked', { + source: `intercom icon`, + page: pathname, + }); + + updateCreditCard({ + licenseKey: activeLicense?.key || '', + successURL: window.location.href, + cancelURL: window.location.href, + }); + }; + + return ( + <> +
+ +
+ + {/* Add Credit Card Modal */} + Add Credit Card for Chat Support} + open={isAddCreditCardModalOpen} + closable + onCancel={(): void => setIsAddCreditCardModalOpen(false)} + destroyOnClose + footer={[ + , + , + ]} + > + + You're currently on Trial plan + . Add a credit card to access SigNoz chat support to your workspace. + + + + ); +} diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx index 114db17924..a3bb980175 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx @@ -287,7 +287,7 @@ function CustomTimePicker({ ) } arrow={false} - trigger="hover" + trigger="click" open={open} onOpenChange={handleOpenChange} style={{ diff --git a/frontend/src/components/DropDown/DropDown.tsx b/frontend/src/components/DropDown/DropDown.tsx index c29fbdd15b..e847e895be 100644 --- a/frontend/src/components/DropDown/DropDown.tsx +++ b/frontend/src/components/DropDown/DropDown.tsx @@ -3,8 +3,15 @@ import './DropDown.styles.scss'; import { EllipsisOutlined } from '@ant-design/icons'; import { Button, Dropdown, MenuProps } from 'antd'; import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useState } from 'react'; -function DropDown({ element }: { element: JSX.Element[] }): JSX.Element { +function DropDown({ + element, + onDropDownItemClick, +}: { + element: JSX.Element[]; + onDropDownItemClick?: MenuProps['onClick']; +}): JSX.Element { const isDarkMode = useIsDarkMode(); const items: MenuProps['items'] = element.map( @@ -14,12 +21,25 @@ function DropDown({ element }: { element: JSX.Element[] }): JSX.Element { }), ); + const [isDdOpen, setDdOpen] = useState(false); + return ( - + setDdOpen(true), + onMouseLeave: (): void => setDdOpen(false), + onClick: (item): void => onDropDownItemClick?.(item), + }} + open={isDdOpen} + > @@ -27,4 +47,8 @@ function DropDown({ element }: { element: JSX.Element[] }): JSX.Element { ); } +DropDown.defaultProps = { + onDropDownItemClick: (): void => {}, +}; + export default DropDown; diff --git a/frontend/src/components/facingIssueBtn/FacingIssueBtn.style.scss b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.styles.scss similarity index 100% rename from frontend/src/components/facingIssueBtn/FacingIssueBtn.style.scss rename to frontend/src/components/LaunchChatSupport/LaunchChatSupport.styles.scss diff --git a/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx new file mode 100644 index 0000000000..eb0659cfb1 --- /dev/null +++ b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx @@ -0,0 +1,191 @@ +import './LaunchChatSupport.styles.scss'; + +import { Button, Modal, Tooltip, Typography } from 'antd'; +import updateCreditCardApi from 'api/billing/checkout'; +import logEvent from 'api/common/logEvent'; +import cx from 'classnames'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { FeatureKeys } from 'constants/features'; +import useFeatureFlags from 'hooks/useFeatureFlag'; +import useLicense from 'hooks/useLicense'; +import { useNotifications } from 'hooks/useNotifications'; +import { defaultTo } from 'lodash-es'; +import { CreditCard, HelpCircle, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useMutation } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; +import { License } from 'types/api/licenses/def'; +import { isCloudUser } from 'utils/app'; + +export interface LaunchChatSupportProps { + eventName: string; + attributes: Record; + message?: string; + buttonText?: string; + className?: string; + onHoverText?: string; + intercomMessageDisabled?: boolean; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +function LaunchChatSupport({ + attributes, + eventName, + message = '', + buttonText = '', + className = '', + onHoverText = '', + intercomMessageDisabled = false, +}: LaunchChatSupportProps): JSX.Element | null { + const isChatSupportEnabled = useFeatureFlags(FeatureKeys.CHAT_SUPPORT)?.active; + const isCloudUserVal = isCloudUser(); + const { notifications } = useNotifications(); + const { data: licenseData, isFetching } = useLicense(); + const [activeLicense, setActiveLicense] = useState(null); + const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] = useState( + false, + ); + + const { pathname } = useLocation(); + const isPremiumChatSupportEnabled = + useFeatureFlags(FeatureKeys.PREMIUM_SUPPORT)?.active || false; + + const showAddCreditCardModal = + !isPremiumChatSupportEnabled && + !licenseData?.payload?.trialConvertedToSubscription; + + useEffect(() => { + const activeValidLicense = + licenseData?.payload?.licenses?.find( + (license) => license.isCurrent === true, + ) || null; + + setActiveLicense(activeValidLicense); + }, [licenseData, isFetching]); + + const handleFacingIssuesClick = (): void => { + if (showAddCreditCardModal) { + logEvent('Disabled Chat Support: Clicked', { + source: `facing issues button`, + page: pathname, + ...attributes, + }); + setIsAddCreditCardModalOpen(true); + } else { + logEvent(eventName, attributes); + if (window.Intercom && !intercomMessageDisabled) { + window.Intercom('showNewMessage', defaultTo(message, '')); + } + } + }; + + const handleBillingOnSuccess = ( + data: ErrorResponse | SuccessResponse, + ): void => { + if (data?.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }; + + const handleBillingOnError = (): void => { + notifications.error({ + message: SOMETHING_WENT_WRONG, + }); + }; + + const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation( + updateCreditCardApi, + { + onSuccess: (data) => { + handleBillingOnSuccess(data); + }, + onError: handleBillingOnError, + }, + ); + + const handleAddCreditCard = (): void => { + logEvent('Add Credit card modal: Clicked', { + source: `facing issues button`, + page: pathname, + ...attributes, + }); + + updateCreditCard({ + licenseKey: activeLicense?.key || '', + successURL: window.location.href, + cancelURL: window.location.href, + }); + }; + + return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future +
+ + + + + {/* Add Credit Card Modal */} + Add Credit Card for Chat Support} + open={isAddCreditCardModalOpen} + closable + onCancel={(): void => setIsAddCreditCardModalOpen(false)} + destroyOnClose + footer={[ + , + , + ]} + > + + You're currently on Trial plan + . Add a credit card to access SigNoz chat support to your workspace. + + +
+ ) : null; +} + +LaunchChatSupport.defaultProps = { + message: '', + buttonText: '', + className: '', + onHoverText: '', + intercomMessageDisabled: false, +}; + +export default LaunchChatSupport; diff --git a/frontend/src/components/facingIssueBtn/util.ts b/frontend/src/components/LaunchChatSupport/util.ts similarity index 85% rename from frontend/src/components/facingIssueBtn/util.ts rename to frontend/src/components/LaunchChatSupport/util.ts index b99a31e970..8b610b11cc 100644 --- a/frontend/src/components/facingIssueBtn/util.ts +++ b/frontend/src/components/LaunchChatSupport/util.ts @@ -41,6 +41,21 @@ I need help with managing alerts. Thanks`; +export const onboardingHelpMessage = ( + dataSourceName: string, + moduleId: string, +): string => `Hi Team, + +I am facing issues sending data to SigNoz. Here are my application details + +Data Source: ${dataSourceName} +Framework: +Environment: +Module: ${moduleId} + +Thanks +`; + export const alertHelpMessage = ( alertDef: AlertDef, ruleId: number, diff --git a/frontend/src/components/LogDetail/LogDetail.interfaces.ts b/frontend/src/components/LogDetail/LogDetail.interfaces.ts index 399e1dffb2..2c56d58fd1 100644 --- a/frontend/src/components/LogDetail/LogDetail.interfaces.ts +++ b/frontend/src/components/LogDetail/LogDetail.interfaces.ts @@ -1,14 +1,22 @@ import { DrawerProps } from 'antd'; import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC'; import { ActionItemProps } from 'container/LogDetailedView/ActionItem'; +import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { VIEWS } from './constants'; export type LogDetailProps = { log: ILog | null; selectedTab: VIEWS; + onGroupByAttribute?: ( + fieldKey: string, + isJSON?: boolean, + dataType?: DataTypes, + ) => Promise; isListViewPanel?: boolean; + listViewPanelSelectedFields?: IField[] | null; } & Pick & Partial> & Pick; diff --git a/frontend/src/components/LogDetail/LogDetails.styles.scss b/frontend/src/components/LogDetail/LogDetails.styles.scss index c8ac0be91f..5cd014b71b 100644 --- a/frontend/src/components/LogDetail/LogDetails.styles.scss +++ b/frontend/src/components/LogDetail/LogDetails.styles.scss @@ -52,7 +52,7 @@ .log-body { font-family: 'SF Mono'; - font-family: 'Space Mono', monospace; + font-family: 'Geist Mono'; font-size: var(--font-size-sm); font-weight: var(--font-weight-normal); diff --git a/frontend/src/components/LogDetail/index.tsx b/frontend/src/components/LogDetail/index.tsx index 0794ead980..b138718ed9 100644 --- a/frontend/src/components/LogDetail/index.tsx +++ b/frontend/src/components/LogDetail/index.tsx @@ -2,14 +2,23 @@ import './LogDetails.styles.scss'; import { Color, Spacing } from '@signozhq/design-tokens'; +import Convert from 'ansi-to-html'; import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd'; import { RadioChangeEvent } from 'antd/lib'; import cx from 'classnames'; import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator'; +import { LOCALSTORAGE } from 'constants/localStorage'; import ContextView from 'container/LogDetailedView/ContextView/ContextView'; import JSONView from 'container/LogDetailedView/JsonView'; import Overview from 'container/LogDetailedView/Overview'; -import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils'; +import { + aggregateAttributesResourcesToString, + removeEscapeCharacters, + unescapeString, +} from 'container/LogDetailedView/utils'; +import { useOptionsMenu } from 'container/OptionsMenu'; +import dompurify from 'dompurify'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { useNotifications } from 'hooks/useNotifications'; import { @@ -21,21 +30,27 @@ import { TextSelect, X, } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useCopyToClipboard } from 'react-use'; import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource, StringOperators } from 'types/common/queryBuilder'; +import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; import { VIEW_TYPES, VIEWS } from './constants'; import { LogDetailProps } from './LogDetail.interfaces'; import QueryBuilderSearchWrapper from './QueryBuilderSearchWrapper'; +const convert = new Convert(); + function LogDetail({ log, onClose, onAddToQuery, + onGroupByAttribute, onClickActionItem, selectedTab, isListViewPanel = false, + listViewPanelSelectedFields, }: LogDetailProps): JSX.Element { const [, copyToClipboard] = useCopyToClipboard(); const [selectedView, setSelectedView] = useState(selectedTab); @@ -45,6 +60,19 @@ function LogDetail({ const [contextQuery, setContextQuery] = useState(); const [filters, setFilters] = useState(null); const [isEdit, setIsEdit] = useState(false); + const { initialDataSource, stagedQuery } = useQueryBuilder(); + + const listQuery = useMemo(() => { + if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null; + + return stagedQuery.builder.queryData.find((item) => !item.disabled) || null; + }, [stagedQuery]); + + const { options } = useOptionsMenu({ + storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, + dataSource: initialDataSource || DataSource.LOGS, + aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP, + }); const isDarkMode = useIsDarkMode(); @@ -71,6 +99,17 @@ function LogDetail({ } }; + const htmlBody = useMemo( + () => ({ + __html: convert.toHtml( + dompurify.sanitize(unescapeString(log?.body || ''), { + FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], + }), + ), + }), + [log?.body], + ); + const handleJSONCopy = (): void => { copyToClipboard(LogJsonData); notifications.success({ @@ -108,8 +147,8 @@ function LogDetail({ >
- - {log?.body} + +
 
@@ -191,7 +230,10 @@ function LogDetail({ logData={log} onAddToQuery={onAddToQuery} onClickActionItem={onClickActionItem} + onGroupByAttribute={onGroupByAttribute} isListViewPanel={isListViewPanel} + selectedOptions={options} + listViewPanelSelectedFields={listViewPanelSelectedFields} /> )} {selectedView === VIEW_TYPES.JSON && } diff --git a/frontend/src/components/Logs/AddToQueryHOC.styles.scss b/frontend/src/components/Logs/AddToQueryHOC.styles.scss index 42baabd02a..ec790ceecc 100644 --- a/frontend/src/components/Logs/AddToQueryHOC.styles.scss +++ b/frontend/src/components/Logs/AddToQueryHOC.styles.scss @@ -1,3 +1,16 @@ .addToQueryContainer { cursor: pointer; + display: flex; + align-items: center; + &.small { + line-height: 16px; + } + + &.medium { + line-height: 20px; + } + + &.large { + line-height: 24px; + } } diff --git a/frontend/src/components/Logs/AddToQueryHOC.tsx b/frontend/src/components/Logs/AddToQueryHOC.tsx index 609840477b..df222b7552 100644 --- a/frontend/src/components/Logs/AddToQueryHOC.tsx +++ b/frontend/src/components/Logs/AddToQueryHOC.tsx @@ -1,18 +1,22 @@ import './AddToQueryHOC.styles.scss'; import { Popover } from 'antd'; +import cx from 'classnames'; import { OPERATORS } from 'constants/queryBuilder'; -import { memo, ReactNode, useCallback, useMemo } from 'react'; +import { FontSize } from 'container/OptionsMenu/types'; +import { memo, MouseEvent, ReactNode, useMemo } from 'react'; function AddToQueryHOC({ fieldKey, fieldValue, onAddToQuery, + fontSize, children, }: AddToQueryHOCProps): JSX.Element { - const handleQueryAdd = useCallback(() => { + const handleQueryAdd = (event: MouseEvent): void => { + event.stopPropagation(); onAddToQuery(fieldKey, fieldValue, OPERATORS.IN); - }, [fieldKey, fieldValue, onAddToQuery]); + }; const popOverContent = useMemo(() => Add to query: {fieldKey}, [ fieldKey, @@ -20,7 +24,7 @@ function AddToQueryHOC({ return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
+
{children} @@ -32,6 +36,7 @@ export interface AddToQueryHOCProps { fieldKey: string; fieldValue: string; onAddToQuery: (fieldKey: string, fieldValue: string, operator: string) => void; + fontSize: FontSize; children: ReactNode; } diff --git a/frontend/src/components/Logs/CopyClipboardHOC.tsx b/frontend/src/components/Logs/CopyClipboardHOC.tsx index a12208bf77..65cb6fc854 100644 --- a/frontend/src/components/Logs/CopyClipboardHOC.tsx +++ b/frontend/src/components/Logs/CopyClipboardHOC.tsx @@ -4,6 +4,7 @@ import { ReactNode, useCallback, useEffect } from 'react'; import { useCopyToClipboard } from 'react-use'; function CopyClipboardHOC({ + entityKey, textToCopy, children, }: CopyClipboardHOCProps): JSX.Element { @@ -11,11 +12,15 @@ function CopyClipboardHOC({ const { notifications } = useNotifications(); useEffect(() => { if (value.value) { + const key = entityKey || ''; + + const notificationMessage = `${key} copied to clipboard`; + notifications.success({ - message: 'Copied to clipboard', + message: notificationMessage, }); } - }, [value, notifications]); + }, [value, notifications, entityKey]); const onClick = useCallback((): void => { setCopy(textToCopy); @@ -34,6 +39,7 @@ function CopyClipboardHOC({ } interface CopyClipboardHOCProps { + entityKey: string | undefined; textToCopy: string; children: ReactNode; } diff --git a/frontend/src/components/Logs/ListLogView/ListLogView.styles.scss b/frontend/src/components/Logs/ListLogView/ListLogView.styles.scss index 3caf6a3282..21dcf171ce 100644 --- a/frontend/src/components/Logs/ListLogView/ListLogView.styles.scss +++ b/frontend/src/components/Logs/ListLogView/ListLogView.styles.scss @@ -6,6 +6,21 @@ font-weight: 400; line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; + + &.small { + font-size: 11px; + line-height: 16px; + } + + &.medium { + font-size: 13px; + line-height: 20px; + } + + &.large { + font-size: 14px; + line-height: 24px; + } } .log-value { color: var(--text-vanilla-400, #c0c1c3); @@ -14,6 +29,21 @@ font-weight: 400; line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; + + &.small { + font-size: 11px; + line-height: 16px; + } + + &.medium { + font-size: 13px; + line-height: 20px; + } + + &.large { + font-size: 14px; + line-height: 24px; + } } .log-line { display: flex; @@ -40,6 +70,20 @@ font-weight: 400; line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; + &.small { + font-size: 11px; + line-height: 16px; + } + + &.medium { + font-size: 13px; + line-height: 20px; + } + + &.large { + font-size: 14px; + line-height: 24px; + } } .selected-log-value { @@ -52,12 +96,37 @@ line-height: 18px; letter-spacing: -0.07px; font-size: 14px; + &.small { + font-size: 11px; + line-height: 16px; + } + + &.medium { + font-size: 13px; + line-height: 20px; + } + + &.large { + font-size: 14px; + line-height: 24px; + } } .selected-log-kv { min-height: 24px; display: flex; align-items: center; + &.small { + min-height: 16px; + } + + &.medium { + min-height: 20px; + } + + &.large { + min-height: 24px; + } } } diff --git a/frontend/src/components/Logs/ListLogView/index.tsx b/frontend/src/components/Logs/ListLogView/index.tsx index fa8a2fb608..ed2627552d 100644 --- a/frontend/src/components/Logs/ListLogView/index.tsx +++ b/frontend/src/components/Logs/ListLogView/index.tsx @@ -3,8 +3,11 @@ import './ListLogView.styles.scss'; import { blue } from '@ant-design/colors'; import Convert from 'ansi-to-html'; import { Typography } from 'antd'; +import cx from 'classnames'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES } from 'components/LogDetail/constants'; +import { unescapeString } from 'container/LogDetailedView/utils'; +import { FontSize } from 'container/OptionsMenu/types'; import dayjs from 'dayjs'; import dompurify from 'dompurify'; import { useActiveLog } from 'hooks/logs/useActiveLog'; @@ -39,6 +42,7 @@ interface LogFieldProps { fieldKey: string; fieldValue: string; linesPerRow?: number; + fontSize: FontSize; } type LogSelectedFieldProps = Omit & @@ -48,11 +52,12 @@ function LogGeneralField({ fieldKey, fieldValue, linesPerRow = 1, + fontSize, }: LogFieldProps): JSX.Element { const html = useMemo( () => ({ __html: convert.toHtml( - dompurify.sanitize(fieldValue, { + dompurify.sanitize(unescapeString(fieldValue), { FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], }), ), @@ -62,12 +67,12 @@ function LogGeneralField({ return ( - + {`${fieldKey} : `} 1 ? linesPerRow : undefined} /> @@ -78,6 +83,7 @@ function LogSelectedField({ fieldKey = '', fieldValue = '', onAddToQuery, + fontSize, }: LogSelectedFieldProps): JSX.Element { return (
@@ -85,16 +91,22 @@ function LogSelectedField({ fieldKey={fieldKey} fieldValue={fieldValue} onAddToQuery={onAddToQuery} + fontSize={fontSize} > - + {fieldKey} - - {': '} - {fieldValue || "''"} + + {': '} + + {fieldValue || "''"} +
); @@ -107,6 +119,7 @@ type ListLogViewProps = { onAddToQuery: AddToQueryHOCProps['onAddToQuery']; activeLog?: ILog | null; linesPerRow: number; + fontSize: FontSize; }; function ListLogView({ @@ -116,6 +129,7 @@ function ListLogView({ onAddToQuery, activeLog, linesPerRow, + fontSize, }: ListLogViewProps): JSX.Element { const flattenLogData = useMemo(() => FlatLogData(logData), [logData]); @@ -128,6 +142,7 @@ function ListLogView({ onAddToQuery: handleAddToQuery, onSetActiveLog: handleSetActiveContextLog, onClearActiveLog: handleClearActiveContextLog, + onGroupByAttribute, } = useActiveLog(); const isDarkMode = useIsDarkMode(); @@ -185,6 +200,7 @@ function ListLogView({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleDetailedView} + fontSize={fontSize} >
- + {flattenLogData.stream && ( - + )} - + {updatedSelecedFields.map((field) => isValidLogField(flattenLogData[field.name] as never) ? ( @@ -212,6 +238,7 @@ function ListLogView({ fieldKey={field.name} fieldValue={flattenLogData[field.name] as never} onAddToQuery={onAddToQuery} + fontSize={fontSize} /> ) : null, )} @@ -232,6 +259,7 @@ function ListLogView({ onAddToQuery={handleAddToQuery} selectedTab={VIEW_TYPES.CONTEXT} onClose={handlerClearActiveContextLog} + onGroupByAttribute={onGroupByAttribute} /> )} diff --git a/frontend/src/components/Logs/ListLogView/styles.ts b/frontend/src/components/Logs/ListLogView/styles.ts index 52cc2b20d4..d2a6342c77 100644 --- a/frontend/src/components/Logs/ListLogView/styles.ts +++ b/frontend/src/components/Logs/ListLogView/styles.ts @@ -1,21 +1,46 @@ +/* eslint-disable no-nested-ternary */ import { Color } from '@signozhq/design-tokens'; import { Card, Typography } from 'antd'; +import { FontSize } from 'container/OptionsMenu/types'; import styled from 'styled-components'; interface LogTextProps { linesPerRow?: number; } +interface LogContainerProps { + fontSize: FontSize; +} + export const Container = styled(Card)<{ $isActiveLog: boolean; $isDarkMode: boolean; + fontSize: FontSize; }>` width: 100% !important; margin-bottom: 0.3rem; + + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `margin-bottom:0.1rem;` + : fontSize === FontSize.MEDIUM + ? `margin-bottom: 0.2rem;` + : fontSize === FontSize.LARGE + ? `margin-bottom:0.3rem;` + : ``} cursor: pointer; .ant-card-body { padding: 0.3rem 0.6rem; + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `padding:0.1rem 0.6rem;` + : fontSize === FontSize.MEDIUM + ? `padding: 0.2rem 0.6rem;` + : fontSize === FontSize.LARGE + ? `padding:0.3rem 0.6rem;` + : ``} + ${({ $isActiveLog, $isDarkMode }): string => $isActiveLog ? `background-color: ${ @@ -38,11 +63,17 @@ export const TextContainer = styled.div` width: 100%; `; -export const LogContainer = styled.div` +export const LogContainer = styled.div` margin-left: 0.5rem; display: flex; flex-direction: column; gap: 6px; + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `gap: 2px;` + : fontSize === FontSize.MEDIUM + ? ` gap:4px;` + : `gap:6px;`} `; export const LogText = styled.div` diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss index a00c7f6761..61870abc71 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss @@ -9,11 +9,24 @@ border-radius: 50px; background-color: transparent; + &.small { + min-height: 16px; + } + + &.medium { + min-height: 20px; + } + + &.large { + min-height: 24px; + } + &.INFO { background-color: var(--bg-slate-400); } - &.WARNING, &.WARN { + &.WARNING, + &.WARN { background-color: var(--bg-amber-500); } diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx index d924c27426..06cc9d3ec4 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx @@ -1,10 +1,13 @@ import { render } from '@testing-library/react'; +import { FontSize } from 'container/OptionsMenu/types'; import LogStateIndicator from './LogStateIndicator'; describe('LogStateIndicator', () => { it('renders correctly with default props', () => { - const { container } = render(); + const { container } = render( + , + ); const indicator = container.firstChild as HTMLElement; expect(indicator.classList.contains('log-state-indicator')).toBe(true); expect(indicator.classList.contains('isActive')).toBe(false); @@ -15,28 +18,30 @@ describe('LogStateIndicator', () => { }); it('renders correctly when isActive is true', () => { - const { container } = render(); + const { container } = render( + , + ); const indicator = container.firstChild as HTMLElement; expect(indicator.classList.contains('isActive')).toBe(true); }); it('renders correctly with different types', () => { const { container: containerInfo } = render( - , + , ); expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe( true, ); const { container: containerWarning } = render( - , + , ); expect( containerWarning.querySelector('.line')?.classList.contains('WARNING'), ).toBe(true); const { container: containerError } = render( - , + , ); expect( containerError.querySelector('.line')?.classList.contains('ERROR'), diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx index 5355e38017..b9afa5b7a2 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx @@ -1,6 +1,7 @@ import './LogStateIndicator.styles.scss'; import cx from 'classnames'; +import { FontSize } from 'container/OptionsMenu/types'; export const SEVERITY_TEXT_TYPE = { TRACE: 'TRACE', @@ -28,24 +29,31 @@ export const SEVERITY_TEXT_TYPE = { FATAL2: 'FATAL2', FATAL3: 'FATAL3', FATAL4: 'FATAL4', + UNKNOWN: 'UNKNOWN', } as const; export const LogType = { + TRACE: 'TRACE', + DEBUG: 'DEBUG', INFO: 'INFO', - WARNING: 'WARNING', + WARN: 'WARN', ERROR: 'ERROR', + FATAL: 'FATAL', + UNKNOWN: 'UNKNOWN', } as const; function LogStateIndicator({ type, isActive, + fontSize, }: { type: string; + fontSize: FontSize; isActive?: boolean; }): JSX.Element { return (
-
+
); } diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.test.ts b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts index 65f6b9664d..17c601ffb4 100644 --- a/frontend/src/components/Logs/LogStateIndicator/utils.test.ts +++ b/frontend/src/components/Logs/LogStateIndicator/utils.test.ts @@ -1,9 +1,10 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { ILog } from 'types/api/logs/log'; import { getLogIndicatorType, getLogIndicatorTypeForTable } from './utils'; describe('getLogIndicatorType', () => { - it('should return severity type for valid log with severityText', () => { + it('severity_number should be given priority over severity_text', () => { const log = { date: '2024-02-29T12:34:46Z', timestamp: 1646115296, @@ -20,11 +21,57 @@ describe('getLogIndicatorType', () => { attributesInt: {}, attributesFloat: {}, severity_text: 'INFO', + severity_number: 2, }; - expect(getLogIndicatorType(log)).toBe('INFO'); + // severity_number should get priority over severity_text + expect(getLogIndicatorType(log)).toBe('TRACE'); }); - it('should return log level if severityText is missing', () => { + it('severity_text should be used when severity_number is absent ', () => { + const log = { + date: '2024-02-29T12:34:46Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityText: 'INFO', + severityNumber: 2, + body: 'Sample log Message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: 'FATAL', + severity_number: 0, + }; + expect(getLogIndicatorType(log)).toBe('FATAL'); + }); + + it('case insensitive severity_text should be valid', () => { + const log = { + date: '2024-02-29T12:34:46Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityText: 'INFO', + severityNumber: 2, + body: 'Sample log Message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: 'fatAl', + severity_number: 0, + }; + expect(getLogIndicatorType(log)).toBe('FATAL'); + }); + + it('should return log level if severityText and severityNumber is missing', () => { const log: ILog = { date: '2024-02-29T12:34:58Z', timestamp: 1646115296, @@ -36,13 +83,16 @@ describe('getLogIndicatorType', () => { body: 'Sample log', resources_string: {}, attributesString: {}, - attributes_string: {}, + attributes_string: { + log_level: 'INFO' as never, + }, attributesInt: {}, attributesFloat: {}, - severity_text: 'FATAL', + severity_text: 'some_random', severityText: '', + severity_number: 0, }; - expect(getLogIndicatorType(log)).toBe('FATAL'); + expect(getLogIndicatorType(log)).toBe('INFO'); }); }); @@ -55,6 +105,7 @@ describe('getLogIndicatorTypeForTable', () => { traceId: '987654', spanId: '54321', traceFlags: 0, + severityNumber: 2, severity_number: 2, body: 'Sample log message', resources_string: {}, @@ -64,7 +115,7 @@ describe('getLogIndicatorTypeForTable', () => { attributesFloat: {}, severity_text: 'WARN', }; - expect(getLogIndicatorTypeForTable(log)).toBe('WARN'); + expect(getLogIndicatorTypeForTable(log)).toBe('TRACE'); }); it('should return log level if severityText is missing', () => { @@ -75,7 +126,8 @@ describe('getLogIndicatorTypeForTable', () => { traceId: '987654', spanId: '54321', traceFlags: 0, - severityNumber: 2, + severityNumber: 0, + severity_number: 0, body: 'Sample log message', resources_string: {}, attributesString: {}, @@ -87,3 +139,47 @@ describe('getLogIndicatorTypeForTable', () => { expect(getLogIndicatorTypeForTable(log)).toBe('INFO'); }); }); + +describe('logIndicatorBySeverityNumber', () => { + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + const logLevelExpectations = [ + { minSevNumber: 1, maxSevNumber: 4, expectedIndicatorType: 'TRACE' }, + { minSevNumber: 5, maxSevNumber: 8, expectedIndicatorType: 'DEBUG' }, + { minSevNumber: 9, maxSevNumber: 12, expectedIndicatorType: 'INFO' }, + { minSevNumber: 13, maxSevNumber: 16, expectedIndicatorType: 'WARN' }, + { minSevNumber: 17, maxSevNumber: 20, expectedIndicatorType: 'ERROR' }, + { minSevNumber: 21, maxSevNumber: 24, expectedIndicatorType: 'FATAL' }, + ]; + logLevelExpectations.forEach((e) => { + for (let sevNum = e.minSevNumber; sevNum <= e.maxSevNumber; sevNum++) { + const sevText = (Math.random() + 1).toString(36).substring(2); + + const log = { + date: '2024-02-29T12:34:46Z', + timestamp: 1646115296, + id: '123456', + traceId: '987654', + spanId: '54321', + traceFlags: 0, + severityText: sevText, + severityNumber: sevNum, + body: 'Sample log Message', + resources_string: {}, + attributesString: {}, + attributes_string: {}, + attributesInt: {}, + attributesFloat: {}, + severity_text: sevText, + severity_number: sevNum, + }; + + it(`getLogIndicatorType should return ${e.expectedIndicatorType} for severity_text: ${sevText} and severity_number: ${sevNum}`, () => { + expect(getLogIndicatorType(log)).toBe(e.expectedIndicatorType); + }); + + it(`getLogIndicatorTypeForTable should return ${e.expectedIndicatorType} for severity_text: ${sevText} and severity_number: ${sevNum}`, () => { + expect(getLogIndicatorTypeForTable(log)).toBe(e.expectedIndicatorType); + }); + } + }); +}); diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.ts b/frontend/src/components/Logs/LogStateIndicator/utils.ts index 7bfe7a430a..03989a8dd6 100644 --- a/frontend/src/components/Logs/LogStateIndicator/utils.ts +++ b/frontend/src/components/Logs/LogStateIndicator/utils.ts @@ -2,56 +2,112 @@ import { ILog } from 'types/api/logs/log'; import { LogType, SEVERITY_TEXT_TYPE } from './LogStateIndicator'; -const getSeverityType = (severityText: string): string => { +const getLogTypeBySeverityText = (severityText: string): string => { switch (severityText) { case SEVERITY_TEXT_TYPE.TRACE: case SEVERITY_TEXT_TYPE.TRACE2: case SEVERITY_TEXT_TYPE.TRACE3: case SEVERITY_TEXT_TYPE.TRACE4: - return SEVERITY_TEXT_TYPE.TRACE; + return LogType.TRACE; case SEVERITY_TEXT_TYPE.DEBUG: case SEVERITY_TEXT_TYPE.DEBUG2: case SEVERITY_TEXT_TYPE.DEBUG3: case SEVERITY_TEXT_TYPE.DEBUG4: - return SEVERITY_TEXT_TYPE.DEBUG; + return LogType.DEBUG; case SEVERITY_TEXT_TYPE.INFO: case SEVERITY_TEXT_TYPE.INFO2: case SEVERITY_TEXT_TYPE.INFO3: case SEVERITY_TEXT_TYPE.INFO4: - return SEVERITY_TEXT_TYPE.INFO; + return LogType.INFO; case SEVERITY_TEXT_TYPE.WARN: case SEVERITY_TEXT_TYPE.WARN2: case SEVERITY_TEXT_TYPE.WARN3: case SEVERITY_TEXT_TYPE.WARN4: case SEVERITY_TEXT_TYPE.WARNING: - return SEVERITY_TEXT_TYPE.WARN; + return LogType.WARN; case SEVERITY_TEXT_TYPE.ERROR: case SEVERITY_TEXT_TYPE.ERROR2: case SEVERITY_TEXT_TYPE.ERROR3: case SEVERITY_TEXT_TYPE.ERROR4: - return SEVERITY_TEXT_TYPE.ERROR; + return LogType.ERROR; case SEVERITY_TEXT_TYPE.FATAL: case SEVERITY_TEXT_TYPE.FATAL2: case SEVERITY_TEXT_TYPE.FATAL3: case SEVERITY_TEXT_TYPE.FATAL4: - return SEVERITY_TEXT_TYPE.FATAL; + return LogType.FATAL; default: - return SEVERITY_TEXT_TYPE.INFO; + return LogType.UNKNOWN; } }; -export const getLogIndicatorType = (logData: ILog): string => { - if (logData.severity_text) { - return getSeverityType(logData.severity_text); +// https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber +const getLogTypeBySeverityNumber = (severityNumber: number): string => { + if (severityNumber < 1) { + return LogType.UNKNOWN; + } + if (severityNumber < 5) { + return LogType.TRACE; + } + if (severityNumber < 9) { + return LogType.DEBUG; + } + if (severityNumber < 13) { + return LogType.INFO; + } + if (severityNumber < 17) { + return LogType.WARN; + } + if (severityNumber < 21) { + return LogType.ERROR; + } + if (severityNumber < 25) { + return LogType.FATAL; + } + return LogType.UNKNOWN; +}; + +const getLogType = ( + severityText: string, + severityNumber: number, + defaultType: string, +): string => { + // give priority to the severityNumber + if (severityNumber) { + const logType = getLogTypeBySeverityNumber(severityNumber); + if (logType !== LogType.UNKNOWN) { + return logType; + } } - return logData.attributes_string?.log_level || LogType.INFO; + + // is severityNumber is not present then rely on the severityText + if (severityText) { + const logType = getLogTypeBySeverityText(severityText); + if (logType !== LogType.UNKNOWN) { + return logType; + } + } + + return defaultType; +}; + +export const getLogIndicatorType = (logData: ILog): string => { + const defaultType = logData.attributes_string?.log_level || LogType.INFO; + // convert the severity_text to upper case for the comparison to support case insensitive values + return getLogType( + logData?.severity_text?.toUpperCase(), + logData?.severity_number || 0, + defaultType, + ); }; export const getLogIndicatorTypeForTable = ( log: Record, ): string => { - if (log.severity_text) { - return getSeverityType(log.severity_text as string); - } - return (log.log_level as string) || LogType.INFO; + const defaultType = (log.log_level as string) || LogType.INFO; + // convert the severity_text to upper case for the comparison to support case insensitive values + return getLogType( + (log?.severity_text as string)?.toUpperCase(), + (log?.severity_number as number) || 0, + defaultType, + ); }; diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index fcb8beeeec..2cda9c7247 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -4,6 +4,7 @@ import Convert from 'ansi-to-html'; import { DrawerProps } from 'antd'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants'; +import { unescapeString } from 'container/LogDetailedView/utils'; import LogsExplorerContext from 'container/LogsExplorerContext'; import dayjs from 'dayjs'; import dompurify from 'dompurify'; @@ -39,6 +40,7 @@ function RawLogView({ linesPerRow, isTextOverflowEllipsisDisabled, selectedFields = [], + fontSize, }: RawLogViewProps): JSX.Element { const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink( data.id, @@ -54,6 +56,7 @@ function RawLogView({ onSetActiveLog, onClearActiveLog, onAddToQuery, + onGroupByAttribute, } = useActiveLog(); const [hasActionButtons, setHasActionButtons] = useState(false); @@ -62,8 +65,6 @@ function RawLogView({ const isDarkMode = useIsDarkMode(); const isReadOnlyLog = !isLogsExplorerPage || isReadOnly; - const severityText = data.severity_text ? `${data.severity_text} |` : ''; - const logType = getLogIndicatorType(data); const updatedSelecedFields = useMemo( @@ -88,17 +89,16 @@ function RawLogView({ attributesText += ' | '; } - const text = useMemo( - () => + const text = useMemo(() => { + const date = typeof data.timestamp === 'string' - ? `${dayjs(data.timestamp).format( - 'YYYY-MM-DD HH:mm:ss.SSS', - )} | ${attributesText} ${severityText} ${data.body}` - : `${dayjs(data.timestamp / 1e6).format( - 'YYYY-MM-DD HH:mm:ss.SSS', - )} | ${attributesText} ${severityText} ${data.body}`, - [data.timestamp, data.body, severityText, attributesText], - ); + ? dayjs(data.timestamp) + : dayjs(data.timestamp / 1e6); + + return `${date.format('YYYY-MM-DD HH:mm:ss.SSS')} | ${attributesText} ${ + data.body + }`; + }, [data.timestamp, data.body, attributesText]); const handleClickExpand = useCallback(() => { if (activeContextLog || isReadOnly) return; @@ -146,7 +146,9 @@ function RawLogView({ const html = useMemo( () => ({ __html: convert.toHtml( - dompurify.sanitize(text, { FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS] }), + dompurify.sanitize(unescapeString(text), { + FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], + }), ), }), [text], @@ -163,6 +165,7 @@ function RawLogView({ $isActiveLog={isActiveLog} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + fontSize={fontSize} > @@ -202,6 +207,7 @@ function RawLogView({ onClose={handleCloseLogDetail} onAddToQuery={onAddToQuery} onClickActionItem={onAddToQuery} + onGroupByAttribute={onGroupByAttribute} /> )} diff --git a/frontend/src/components/Logs/RawLogView/styles.ts b/frontend/src/components/Logs/RawLogView/styles.ts index 357eed0324..d464f35910 100644 --- a/frontend/src/components/Logs/RawLogView/styles.ts +++ b/frontend/src/components/Logs/RawLogView/styles.ts @@ -1,6 +1,8 @@ +/* eslint-disable no-nested-ternary */ import { blue } from '@ant-design/colors'; import { Color } from '@signozhq/design-tokens'; import { Col, Row, Space } from 'antd'; +import { FontSize } from 'container/OptionsMenu/types'; import styled from 'styled-components'; import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs'; @@ -11,6 +13,7 @@ export const RawLogViewContainer = styled(Row)<{ $isReadOnly?: boolean; $isActiveLog?: boolean; $isHightlightedLog: boolean; + fontSize: FontSize; }>` position: relative; width: 100%; @@ -22,6 +25,13 @@ export const RawLogViewContainer = styled(Row)<{ .log-state-indicator { margin: 4px 0; + + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `margin: 1px 0;` + : fontSize === FontSize.MEDIUM + ? `margin: 1px 0;` + : `margin: 2px 0;`} } ${({ $isActiveLog }): string => getActiveLogBackground($isActiveLog)} @@ -49,9 +59,9 @@ export const ExpandIconWrapper = styled(Col)` export const RawLogContent = styled.div` margin-bottom: 0; font-family: 'SF Mono', monospace; - font-family: 'Space Mono', monospace; - font-size: 13px; - font-weight: 400; + font-family: 'Geist Mono'; + letter-spacing: -0.07px; + padding: 4px; text-align: left; color: ${({ $isDarkMode }): string => $isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}; @@ -66,9 +76,15 @@ export const RawLogContent = styled.div` line-clamp: ${linesPerRow}; -webkit-box-orient: vertical;`}; + font-size: 13px; + font-weight: 400; line-height: 24px; - letter-spacing: -0.07px; - padding: 4px; + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `font-size:11px; line-height:16px; padding:1px;` + : fontSize === FontSize.MEDIUM + ? `font-size:13px; line-height:20px; padding:1px;` + : `font-size:14px; line-height:24px; padding:2px;`} cursor: ${({ $isActiveLog, $isReadOnly }): string => $isActiveLog || $isReadOnly ? 'initial' : 'pointer'}; diff --git a/frontend/src/components/Logs/RawLogView/types.ts b/frontend/src/components/Logs/RawLogView/types.ts index a9c85c2ad6..ed73725dcc 100644 --- a/frontend/src/components/Logs/RawLogView/types.ts +++ b/frontend/src/components/Logs/RawLogView/types.ts @@ -1,3 +1,4 @@ +import { FontSize } from 'container/OptionsMenu/types'; import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; @@ -7,11 +8,13 @@ export interface RawLogViewProps { isTextOverflowEllipsisDisabled?: boolean; data: ILog; linesPerRow: number; + fontSize: FontSize; selectedFields?: IField[]; } export interface RawLogContentProps { linesPerRow: number; + fontSize: FontSize; $isReadOnly?: boolean; $isActiveLog?: boolean; $isDarkMode?: boolean; diff --git a/frontend/src/components/Logs/TableView/styles.ts b/frontend/src/components/Logs/TableView/styles.ts index 9213021971..a79db04a76 100644 --- a/frontend/src/components/Logs/TableView/styles.ts +++ b/frontend/src/components/Logs/TableView/styles.ts @@ -1,7 +1,10 @@ +/* eslint-disable no-nested-ternary */ +import { FontSize } from 'container/OptionsMenu/types'; import styled from 'styled-components'; interface TableBodyContentProps { linesPerRow: number; + fontSize: FontSize; isDarkMode?: boolean; } @@ -20,4 +23,10 @@ export const TableBodyContent = styled.div` -webkit-line-clamp: ${(props): number => props.linesPerRow}; line-clamp: ${(props): number => props.linesPerRow}; -webkit-box-orient: vertical; + ${({ fontSize }): string => + fontSize === FontSize.SMALL + ? `font-size:11px; line-height:16px;` + : fontSize === FontSize.MEDIUM + ? `font-size:13px; line-height:20px;` + : `font-size:14px; line-height:24px;`} `; diff --git a/frontend/src/components/Logs/TableView/types.ts b/frontend/src/components/Logs/TableView/types.ts index 36a796ac0f..b2d3670dd8 100644 --- a/frontend/src/components/Logs/TableView/types.ts +++ b/frontend/src/components/Logs/TableView/types.ts @@ -1,4 +1,5 @@ import { ColumnsType, ColumnType } from 'antd/es/table'; +import { FontSize } from 'container/OptionsMenu/types'; import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; @@ -10,6 +11,7 @@ export type LogsTableViewProps = { logs: ILog[]; fields: IField[]; linesPerRow: number; + fontSize: FontSize; onClickExpand?: (log: ILog) => void; }; diff --git a/frontend/src/components/Logs/TableView/useTableView.styles.scss b/frontend/src/components/Logs/TableView/useTableView.styles.scss index 3723ecc705..9592d0ae12 100644 --- a/frontend/src/components/Logs/TableView/useTableView.styles.scss +++ b/frontend/src/components/Logs/TableView/useTableView.styles.scss @@ -5,6 +5,21 @@ font-weight: 400; line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; + + &.small { + font-size: 11px; + line-height: 16px; + } + + &.medium { + font-size: 13px; + line-height: 20px; + } + + &.large { + font-size: 14px; + line-height: 24px; + } } .table-timestamp { @@ -25,3 +40,21 @@ color: var(--bg-slate-400); } } + +.paragraph { + padding: 0px !important; + &.small { + font-size: 11px !important; + line-height: 16px !important; + } + + &.medium { + font-size: 13px !important; + line-height: 20px !important; + } + + &.large { + font-size: 14px !important; + line-height: 24px !important; + } +} diff --git a/frontend/src/components/Logs/TableView/useTableView.tsx b/frontend/src/components/Logs/TableView/useTableView.tsx index fd37132110..43b4ba2628 100644 --- a/frontend/src/components/Logs/TableView/useTableView.tsx +++ b/frontend/src/components/Logs/TableView/useTableView.tsx @@ -3,6 +3,8 @@ import './useTableView.styles.scss'; import Convert from 'ansi-to-html'; import { Typography } from 'antd'; import { ColumnsType } from 'antd/es/table'; +import cx from 'classnames'; +import { unescapeString } from 'container/LogDetailedView/utils'; import dayjs from 'dayjs'; import dompurify from 'dompurify'; import { useIsDarkMode } from 'hooks/useDarkMode'; @@ -31,6 +33,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { logs, fields, linesPerRow, + fontSize, appendTo = 'center', activeContextLog, activeLog, @@ -57,7 +60,10 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { : getDefaultCellStyle(isDarkMode), }, children: ( - + {field} ), @@ -87,8 +93,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { isActive={ activeLog?.id === item.id || activeContextLog?.id === item.id } + fontSize={fontSize} /> - + {date}
@@ -109,11 +116,12 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { @@ -130,6 +138,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { linesPerRow, activeLog?.id, activeContextLog?.id, + fontSize, ]); return { columns, dataSource: flattenLogData }; diff --git a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss index af325a2d25..070d440781 100644 --- a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss +++ b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.styles.scss @@ -17,17 +17,126 @@ box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); backdrop-filter: blur(20px); + .font-size-dropdown { + display: flex; + flex-direction: column; + + .back-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 12px; + border: none !important; + box-shadow: none !important; + + .icon { + flex-shrink: 0; + } + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + letter-spacing: 0.14px; + } + } + + .back-btn:hover { + background-color: unset !important; + } + + .content { + display: flex; + flex-direction: column; + .option-btn { + display: flex; + align-items: center; + padding: 12px; + border: none !important; + box-shadow: none !important; + justify-content: space-between; + + .icon { + flex-shrink: 0; + } + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; /* 142.857% */ + letter-spacing: 0.14px; + text-transform: capitalize; + } + + .text:hover { + color: var(--bg-vanilla-300); + } + } + + .option-btn:hover { + background-color: unset !important; + } + } + } + + .font-size-container { + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + + .title { + color: var(--bg-slate-50); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.88px; + text-transform: uppercase; + } + + .value { + display: flex; + height: 20px; + padding: 4px 0px; + justify-content: space-between; + align-items: center; + border: none !important; + .font-value { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: 0.14px; + text-transform: capitalize; + } + .icon { + } + } + + .value:hover { + background-color: unset !important; + } + } + .menu-container { padding: 12px; .title { font-family: Inter; font-size: 11px; - font-weight: 600; + font-weight: 500; line-height: 18px; letter-spacing: 0.08em; text-align: left; - color: #52575c; + color: var(--bg-slate-50); } .menu-items { @@ -65,11 +174,11 @@ padding: 12px; .title { - color: #52575c; + color: var(--bg-slate-50); font-family: Inter; font-size: 11px; font-style: normal; - font-weight: 600; + font-weight: 500; line-height: 18px; /* 163.636% */ letter-spacing: 0.88px; text-transform: uppercase; @@ -149,11 +258,11 @@ } .title { - color: #52575c; + color: var(--bg-slate-50); font-family: Inter; font-size: 11px; font-style: normal; - font-weight: 600; + font-weight: 500; line-height: 18px; /* 163.636% */ letter-spacing: 0.88px; text-transform: uppercase; @@ -299,6 +408,38 @@ box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2); + .font-size-dropdown { + .back-btn { + .text { + color: var(--bg-ink-400); + } + } + + .content { + .option-btn { + .text { + color: var(--bg-ink-400); + } + + .text:hover { + color: var(--bg-ink-300); + } + } + } + } + + .font-size-container { + .title { + color: var(--bg-ink-100); + } + + .value { + .font-value { + color: var(--bg-ink-400); + } + } + } + .horizontal-line { background: var(--bg-vanilla-300); } diff --git a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx index 3a42e9a0b0..527c77c6af 100644 --- a/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx +++ b/frontend/src/components/LogsFormatOptionsMenu/LogsFormatOptionsMenu.tsx @@ -3,12 +3,12 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ import './LogsFormatOptionsMenu.styles.scss'; -import { Divider, Input, InputNumber, Tooltip } from 'antd'; +import { Button, Divider, Input, InputNumber, Tooltip, Typography } from 'antd'; import cx from 'classnames'; import { LogViewMode } from 'container/LogsTable'; -import { OptionsMenuConfig } from 'container/OptionsMenu/types'; +import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types'; import useDebouncedFn from 'hooks/useDebouncedFunction'; -import { Check, Minus, Plus, X } from 'lucide-react'; +import { Check, ChevronLeft, ChevronRight, Minus, Plus, X } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; interface LogsFormatOptionsMenuProps { @@ -24,10 +24,16 @@ export default function LogsFormatOptionsMenu({ selectedOptionFormat, config, }: LogsFormatOptionsMenuProps): JSX.Element { - const { maxLines, format, addColumn } = config; + const { maxLines, format, addColumn, fontSize } = config; const [selectedItem, setSelectedItem] = useState(selectedOptionFormat); const maxLinesNumber = (maxLines?.value as number) || 1; const [maxLinesPerRow, setMaxLinesPerRow] = useState(maxLinesNumber); + const [fontSizeValue, setFontSizeValue] = useState( + fontSize?.value || FontSize.SMALL, + ); + const [isFontSizeOptionsOpen, setIsFontSizeOptionsOpen] = useState( + false, + ); const [addNewColumn, setAddNewColumn] = useState(false); @@ -88,6 +94,12 @@ export default function LogsFormatOptionsMenu({ } }, [maxLinesPerRow]); + useEffect(() => { + if (fontSizeValue && config && config.fontSize?.onChange) { + config.fontSize.onChange(fontSizeValue); + } + }, [fontSizeValue]); + return (
-
-
{title}
- -
- {items.map( - (item: any): JSX.Element => ( -
handleMenuItemClick(item.key)} - > -
- {item.label} - - {selectedItem === item.key && } -
-
- ), - )} + {isFontSizeOptionsOpen ? ( +
+ +
+
+ + + +
-
- - {selectedItem && ( + ) : ( <> - <> -
-
-
max lines per row
-
- - - -
-
- - -
- {!addNewColumn &&
} - - {addNewColumn && ( -
-
- {' '} - columns - {' '} -
+
+
Font Size
+ +
+
+
+
{title}
- -
- )} +
+ {items.map( + (item: any): JSX.Element => ( +
handleMenuItemClick(item.key)} + > +
+ {item.label} -
- {!addNewColumn && ( -
- columns - {' '} -
+ {selectedItem === item.key && } +
+
+ ), )} +
+
-
- {addColumn?.value?.map(({ key, id }) => ( -
-
- - {key} - -
- addColumn.onRemove(id as string)} + {selectedItem && ( + <> + <> +
+
+
max lines per row
+
+ + +
- ))} -
+
+ - {addColumn?.isFetching && ( -
Loading ...
- )} +
+ {!addNewColumn &&
} + + {addNewColumn && ( +
+
+ {' '} + columns + {' '} +
- {addNewColumn && - addColumn && - addColumn.value.length > 0 && - addColumn.options && - addColumn?.options?.length > 0 && ( - + +
)} - {addNewColumn && ( -
- {addColumn?.options?.map(({ label, value }) => ( -
{ - eve.stopPropagation(); - - if (addColumn && addColumn?.onSelect) { - addColumn?.onSelect(value, { label, disabled: false }); - } - }} - > -
- - {label} - +
+ {!addNewColumn && ( +
+ columns + {' '} +
+ )} + +
+ {addColumn?.value?.map(({ key, id }) => ( +
+
+ + {key} + +
+ addColumn.onRemove(id as string)} + />
+ ))} +
+ + {addColumn?.isFetching && ( +
Loading ...
+ )} + + {addNewColumn && + addColumn && + addColumn.value.length > 0 && + addColumn.options && + addColumn?.options?.length > 0 && ( + + )} + + {addNewColumn && ( +
+ {addColumn?.options?.map(({ label, value }) => ( +
{ + eve.stopPropagation(); + + if (addColumn && addColumn?.onSelect) { + addColumn?.onSelect(value, { label, disabled: false }); + } + }} + > +
+ + {label} + +
+
+ ))}
- ))} + )}
- )} -
-
+
+ + )} )}
diff --git a/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx b/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx new file mode 100644 index 0000000000..73c95ce27d --- /dev/null +++ b/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx @@ -0,0 +1,54 @@ +import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar'; +import VirtuosoOverlayScrollbar from 'components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { PartialOptions } from 'overlayscrollbars'; +import { CSSProperties, ReactElement, useMemo } from 'react'; + +type Props = { + children: ReactElement; + isVirtuoso?: boolean; + style?: CSSProperties; + options?: PartialOptions; +}; + +function OverlayScrollbar({ + children, + isVirtuoso, + style, + options: customOptions, +}: Props): any { + const isDarkMode = useIsDarkMode(); + const options = useMemo( + () => + ({ + scrollbars: { + autoHide: 'scroll', + theme: isDarkMode ? 'os-theme-light' : 'os-theme-dark', + }, + ...(customOptions || {}), + } as PartialOptions), + [customOptions, isDarkMode], + ); + + if (isVirtuoso) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +OverlayScrollbar.defaultProps = { + isVirtuoso: false, + style: {}, + options: {}, +}; + +export default OverlayScrollbar; diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss new file mode 100644 index 0000000000..c46d9975f4 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -0,0 +1,145 @@ +.checkbox-filter { + display: flex; + flex-direction: column; + padding: 12px; + gap: 12px; + border-bottom: 1px solid var(--bg-slate-400); + .filter-header-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + + .left-action { + display: flex; + align-items: center; + gap: 6px; + + .title { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + text-transform: capitalize; + } + } + + .right-action { + display: flex; + align-items: center; + + .clear-all { + font-size: 12px; + color: var(--bg-robin-500); + cursor: pointer; + } + } + } + + .values { + display: flex; + flex-direction: column; + gap: 8px; + + .value { + display: flex; + align-items: center; + gap: 8px; + + .checkbox-value-section { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + + &.filter-disabled { + cursor: not-allowed; + + .value-string { + color: var(--bg-slate-200); + } + + .only-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + + .toggle-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + } + + .value-string { + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .toggle-btn:hover { + background-color: unset; + } + + .only-btn:hover { + background-color: unset; + } + } + + .checkbox-value-section:hover { + .toggle-btn { + display: none; + } + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .value:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .no-data { + align-self: center; + } + + .show-more { + display: flex; + align-items: center; + justify-content: center; + + .show-more-text { + color: var(--bg-robin-500); + cursor: pointer; + } + } +} + +.lightMode { + .checkbox-filter { + border-bottom: 1px solid var(--bg-vanilla-300); + .filter-header-checkbox { + .left-action { + .title { + color: var(--bg-ink-400); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..dcf3cc8f3e --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -0,0 +1,510 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './Checkbox.styles.scss'; + +import { Button, Checkbox, Input, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; +import { OPERATORS } from 'constants/queryBuilder'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +const SELECTED_OPERATORS = [OPERATORS['='], 'in']; +const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin']; + +function setDefaultValues( + values: string[], + trueOrFalse: boolean, +): Record { + const defaultState: Record = {}; + values.forEach((val) => { + defaultState[val] = trueOrFalse; + }); + return defaultState; +} +interface ICheckboxProps { + filter: IQuickFiltersConfig; +} + +export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { + const { filter } = props; + const [searchText, setSearchText] = useState(''); + const [isOpen, setIsOpen] = useState(filter.defaultOpen); + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + + const { + lastUsedQuery, + currentQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + const { data, isLoading } = useGetAggregateValues( + { + aggregateOperator: 'noop', + dataSource: DataSource.LOGS, + aggregateAttribute: '', + attributeKey: filter.attributeKey.key, + filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY, + tagType: filter.attributeKey.type || '', + searchText: searchText ?? '', + }, + { + enabled: isOpen, + keepPreviousData: true, + }, + ); + + const attributeValues: string[] = useMemo( + () => + ((Object.values(data?.payload || {}).find((el) => !!el) || + []) as string[]).filter((val) => !isEmpty(val)), + [data?.payload], + ); + const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount); + + // derive the state of each filter key here in the renderer itself and keep it in sync with staged query + // also we need to keep a note of last focussed query. + // eslint-disable-next-line sonarjs/cognitive-complexity + const currentFilterState = useMemo(() => { + let filterState: Record = setDefaultValues( + attributeValues, + false, + ); + const filterSync = currentQuery?.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items.find((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ); + + if (filterSync) { + if (SELECTED_OPERATORS.includes(filterSync.op)) { + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = true; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = true; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = true; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = true; + } + } else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) { + filterState = setDefaultValues(attributeValues, true); + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = false; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = false; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = false; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = false; + } + } + } else { + filterState = setDefaultValues(attributeValues, true); + } + return filterState; + }, [ + attributeValues, + currentQuery?.builder.queryData, + filter.attributeKey, + lastUsedQuery, + ]); + + // disable the filter when there are multiple entries of the same attribute key present in the filter bar + const isFilterDisabled = useMemo( + () => + (currentQuery?.builder?.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.filter((item) => + isEqual(item.key?.key, filter.attributeKey.key), + )?.length || 0) > 1, + + [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], + ); + + // variable to check if the current filter has multiple values to its name in the key op value section + const isMultipleValuesTrueForTheKey = + Object.values(currentFilterState).filter((val) => val).length > 1; + + const handleClearFilterAttribute = (): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: + idx === lastUsedQuery + ? item.filters.items.filter( + (fil) => !isEqual(fil.key?.key, filter.attributeKey.key), + ) + : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.some((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ); + + const onChange = ( + value: string, + checked: boolean, + isOnlyOrAllClicked: boolean, + // eslint-disable-next-line sonarjs/cognitive-complexity + ): void => { + const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]); + + // if only or all are clicked we do not need to worry about anything just override whatever we have + // by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL. + if (isOnlyOrAllClicked && query?.filters?.items) { + const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute + ? currentFilterState[value] && !isMultipleValuesTrueForTheKey + ? 'All' + : 'Only' + : 'Only'; + query.filters.items = query.filters.items.filter( + (q) => !isEqual(q.key?.key, filter.attributeKey.key), + ); + if (isOnlyOrAll === 'Only') { + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.IN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } else if (query?.filters?.items) { + if ( + query.filters?.items?.some((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ) + ) { + // if there is already a running filter for the current attribute key then + // we split the cases by which particular operator is present right now! + const currentFilter = query.filters?.items?.find((q) => + isEqual(q.key?.key, filter.attributeKey.key), + ); + if (currentFilter) { + const runningOperator = currentFilter?.op; + switch (runningOperator) { + case 'in': + if (checked) { + // if it's an IN operator then if we are checking another value it get's added to the + // filter clause. example - key IN [value1, currentSelectedValue] + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } else { + // if the current state wasn't an array we make it one and add our value + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } + } else if (!checked) { + // if we are removing some value when the running operator is IN we filter. + // example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } + } else { + // if not an array remove the whole thing altogether! + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } + } + break; + case 'nin': + // if the current running operator is NIN then when unchecking the value it gets + // added to the clause like key NIN [value1 , currentUnselectedValue] + if (!checked) { + // in case of array add the currentUnselectedValue to the list. + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } else { + // in case of not an array make it one! + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } + } else if (checked) { + // opposite of above! + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } + } else { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } + } + break; + case '=': + if (checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.IN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } else if (!checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } + break; + case '!=': + if (!checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.NIN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key?.key, filter.attributeKey.key)) { + return newFilter; + } + return item; + }); + } else if (checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key?.key, filter.attributeKey.key), + ); + } + break; + default: + break; + } + } + } else { + // case - when there is no filter for the current key that means all are selected right now. + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.NIN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } + const finalQuery = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + ...currentQuery.builder.queryData.map((q, idx) => { + if (idx === lastUsedQuery) { + return query; + } + return q; + }), + ], + }, + }; + + redirectWithQueryBuilderData(finalQuery); + }; + + return ( +
+
+
+ {isOpen ? ( + { + setIsOpen(false); + setVisibleItemsCount(10); + }} + /> + ) : ( + setIsOpen(true)} + cursor="pointer" + /> + )} + {filter.title} +
+
+ {isOpen && ( + + Clear All + + )} +
+
+ {isOpen && isLoading && !attributeValues.length && ( +
+ +
+ )} + {isOpen && !isLoading && ( + <> +
+ setSearchText(e.target.value)} + disabled={isFilterDisabled} + /> +
+ {attributeValues.length > 0 ? ( +
+ {currentAttributeKeys.map((value: string) => ( +
+ onChange(value, e.target.checked, false)} + checked={currentFilterState[value]} + disabled={isFilterDisabled} + rootClassName="check-box" + /> + +
{ + if (isFilterDisabled) { + return; + } + onChange(value, currentFilterState[value], true); + }} + > + {filter.customRendererForValue ? ( + filter.customRendererForValue(value) + ) : ( + + {value} + + )} + + +
+
+ ))} +
+ ) : ( +
+ No values found{' '} +
+ )} + {visibleItemsCount < attributeValues?.length && ( +
+ setVisibleItemsCount((prev) => prev + 10)} + > + Show More... + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx new file mode 100644 index 0000000000..f7cd9547e8 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx @@ -0,0 +1,14 @@ +import './Slider.styles.scss'; + +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; + +interface ISliderProps { + filter: IQuickFiltersConfig; +} + +// not needed for now build when required +export default function Slider(props: ISliderProps): JSX.Element { + const { filter } = props; + console.log(filter); + return
Slider
; +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.styles.scss b/frontend/src/components/QuickFilters/QuickFilters.styles.scss new file mode 100644 index 0000000000..d5c3460891 --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.styles.scss @@ -0,0 +1,93 @@ +.quick-filters { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid var(--bg-slate-400); + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10.5px; + border-bottom: 1px solid var(--bg-slate-400); + + .left-actions { + display: flex; + align-items: center; + gap: 6px; + + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + } + + .sync-tag { + display: flex; + padding: 5px 9px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 128.571% */ + text-transform: uppercase; + } + } + + .right-actions { + display: flex; + align-items: center; + gap: 12px; + + .divider-filter { + width: 1px; + height: 14px; + background: #161922; + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + } + } + } +} + +.lightMode { + .quick-filters { + background-color: var(--bg-vanilla-100); + border-right: 1px solid var(--bg-vanilla-300); + + .header { + border-bottom: 1px solid var(--bg-vanilla-300); + + .left-actions { + .text { + color: var(--bg-ink-400); + } + + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + .right-actions { + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx new file mode 100644 index 0000000000..a706e35aef --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -0,0 +1,124 @@ +import './QuickFilters.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import Checkbox from './FilterRenderers/Checkbox/Checkbox'; +import Slider from './FilterRenderers/Slider/Slider'; + +export enum FiltersType { + SLIDER = 'SLIDER', + CHECKBOX = 'CHECKBOX', +} + +export enum MinMax { + MIN = 'MIN', + MAX = 'MAX', +} + +export enum SpecficFilterOperations { + ALL = 'ALL', + ONLY = 'ONLY', +} + +export interface IQuickFiltersConfig { + type: FiltersType; + title: string; + attributeKey: BaseAutocompleteData; + customRendererForValue?: (value: string) => JSX.Element; + defaultOpen: boolean; +} + +interface IQuickFiltersProps { + config: IQuickFiltersConfig[]; + handleFilterVisibilityChange: () => void; +} + +export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { + const { config, handleFilterVisibilityChange } = props; + + const { + currentQuery, + lastUsedQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + // clear all the filters for the query which is in sync with filters + const handleReset = (): void => { + const updatedQuery = cloneDeep( + currentQuery?.builder.queryData?.[lastUsedQuery || 0], + ); + + if (!updatedQuery) { + return; + } + + if (updatedQuery?.filters?.items) { + updatedQuery.filters.items = []; + } + + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: idx === lastUsedQuery ? [] : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const lastQueryName = + currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; + return ( +
+
+
+ + Filters for + + {lastQueryName} + +
+
+ + + +
+ + + +
+
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ; + case FiltersType.SLIDER: + return ; + default: + return ; + } + })} +
+
+ ); +} diff --git a/frontend/src/components/ResizeTable/DynamicColumnTable.tsx b/frontend/src/components/ResizeTable/DynamicColumnTable.tsx index fb5d734ee8..53cccbe546 100644 --- a/frontend/src/components/ResizeTable/DynamicColumnTable.tsx +++ b/frontend/src/components/ResizeTable/DynamicColumnTable.tsx @@ -2,8 +2,10 @@ import './DynamicColumnTable.syles.scss'; import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd'; +import { ColumnGroupType, ColumnType } from 'antd/es/table'; import { ColumnsType } from 'antd/lib/table'; -import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; +import logEvent from 'api/common/logEvent'; +import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport'; import { SlidersHorizontal } from 'lucide-react'; import { memo, useEffect, useState } from 'react'; import { popupContainer } from 'utils/selectPopupContainer'; @@ -22,6 +24,7 @@ function DynamicColumnTable({ dynamicColumns, onDragColumn, facingIssueBtn, + shouldSendAlertsLogEvent, ...restProps }: DynamicColumnTableProps): JSX.Element { const [columnsData, setColumnsData] = useState( @@ -47,11 +50,18 @@ function DynamicColumnTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [columns, dynamicColumns]); - const onToggleHandler = (index: number) => ( - checked: boolean, - event: React.MouseEvent, - ): void => { + const onToggleHandler = ( + index: number, + column: ColumnGroupType | ColumnType, + ) => (checked: boolean, event: React.MouseEvent): void => { event.stopPropagation(); + + if (shouldSendAlertsLogEvent) { + logEvent('Alert: Column toggled', { + column: column?.title, + action: checked ? 'Enable' : 'Disable', + }); + } setVisibleColumns({ tablesource, dynamicColumns, @@ -75,7 +85,7 @@ function DynamicColumnTable({
{column.title?.toString()}
c.key === column.key) !== -1} - onChange={onToggleHandler(index)} + onChange={onToggleHandler(index, column)} />
), @@ -86,7 +96,7 @@ function DynamicColumnTable({ return (
- {facingIssueBtn && } + {facingIssueBtn && } {dynamicColumns && ( ([]); @@ -58,14 +60,21 @@ function ResizeTable({ [columnsData, onDragColumn, handleResize], ); - const tableParams = useMemo( - () => ({ + const tableParams = useMemo(() => { + const props = { ...restProps, components: { header: { cell: ResizableHeader } }, columns: mergedColumns, - }), - [mergedColumns, restProps], - ); + }; + + set( + props, + 'pagination', + pagination ? { ...pagination, hideOnSinglePage: true } : false, + ); + + return props; + }, [mergedColumns, pagination, restProps]); useEffect(() => { if (columns) { diff --git a/frontend/src/components/ResizeTable/types.ts b/frontend/src/components/ResizeTable/types.ts index 35a13127a8..1ad3c3318a 100644 --- a/frontend/src/components/ResizeTable/types.ts +++ b/frontend/src/components/ResizeTable/types.ts @@ -2,7 +2,7 @@ import { TableProps } from 'antd'; import { ColumnsType } from 'antd/es/table'; import { ColumnGroupType, ColumnType } from 'antd/lib/table'; -import { FacingIssueBtnProps } from 'components/facingIssueBtn/FacingIssueBtn'; +import { LaunchChatSupportProps } from 'components/LaunchChatSupport/LaunchChatSupport'; import { TableDataSource } from './contants'; @@ -13,7 +13,8 @@ export interface DynamicColumnTableProps extends TableProps { tablesource: typeof TableDataSource[keyof typeof TableDataSource]; dynamicColumns: TableProps['columns']; onDragColumn?: (fromIndex: number, toIndex: number) => void; - facingIssueBtn?: FacingIssueBtnProps; + facingIssueBtn?: LaunchChatSupportProps; + shouldSendAlertsLogEvent?: boolean; } export type GetVisibleColumnsFunction = ( diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss new file mode 100644 index 0000000000..f3c2ea622a --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss @@ -0,0 +1,5 @@ +.tab-title { + display: flex; + gap: 4px; + align-items: center; +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx new file mode 100644 index 0000000000..981c291146 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import './Tabs.styles.scss'; + +import { Radio } from 'antd'; +import { RadioChangeEvent } from 'antd/lib'; +import { History, Table } from 'lucide-react'; +import { useState } from 'react'; + +import { ALERT_TABS } from '../constants'; + +export function Tabs(): JSX.Element { + const [selectedTab, setSelectedTab] = useState('overview'); + + const handleTabChange = (e: RadioChangeEvent): void => { + setSelectedTab(e.target.value); + }; + + return ( + + +
+ + Overview + + + +
+ + History +
+
+ + ); +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..5115eabe2e --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,18 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tabs-and-filters { + @include flex-center; + margin-top: 1rem; + margin-bottom: 1rem; + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..ac6738d491 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,16 @@ +import './TabsAndFilters.styles.scss'; + +import { Filters } from 'components/AlertDetailsFilters/Filters'; + +import { Tabs } from './Tabs/Tabs'; + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/components/TabsAndFilters/constants.ts b/frontend/src/components/TabsAndFilters/constants.ts new file mode 100644 index 0000000000..b052c0e4cf --- /dev/null +++ b/frontend/src/components/TabsAndFilters/constants.ts @@ -0,0 +1,5 @@ +export const ALERT_TABS = { + OVERVIEW: 'OVERVIEW', + HISTORY: 'HISTORY', + ACTIVITY: 'ACTIVITY', +} as const; diff --git a/frontend/src/components/Tags/Tags.tsx b/frontend/src/components/Tags/Tags.tsx index ac38e0e58c..7257594e7e 100644 --- a/frontend/src/components/Tags/Tags.tsx +++ b/frontend/src/components/Tags/Tags.tsx @@ -5,7 +5,6 @@ import { Button } from 'antd'; import { Tag } from 'antd/lib'; import Input from 'components/Input'; import { Check, X } from 'lucide-react'; -import { TweenOneGroup } from 'rc-tween-one'; import React, { Dispatch, SetStateAction, useState } from 'react'; function Tags({ tags, setTags }: AddTagsProps): JSX.Element { @@ -46,41 +45,19 @@ function Tags({ tags, setTags }: AddTagsProps): JSX.Element { func(value); }; - const forMap = (tag: string): React.ReactElement => ( - - { - e.preventDefault(); - handleClose(tag); - }} - > - {tag} - - - ); - - const tagChild = tags.map(forMap); - - const renderTagsAnimated = (): React.ReactElement => ( - { - if (e.type === 'appear' || e.type === 'enter') { - (e.target as any).style = 'display: inline-block'; - } - }} - > - {tagChild} - - ); - return (
- {renderTagsAnimated()} + {tags.map((tag) => ( + handleClose(tag)} + > + {tag} + + ))} + {inputVisible && (
+ {useFilledIcon ? ( ) : ( diff --git a/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx b/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx new file mode 100644 index 0000000000..1ed1717d6d --- /dev/null +++ b/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx @@ -0,0 +1,31 @@ +import './typicalOverlayScrollbar.scss'; + +import { PartialOptions } from 'overlayscrollbars'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { CSSProperties, ReactElement } from 'react'; + +interface Props { + children: ReactElement; + style?: CSSProperties; + options?: PartialOptions; +} + +export default function TypicalOverlayScrollbar({ + children, + style, + options, +}: Props): ReturnType { + return ( + + {children} + + ); +} + +TypicalOverlayScrollbar.defaultProps = { style: {}, options: {} }; diff --git a/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss b/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss new file mode 100644 index 0000000000..9dd4c3b381 --- /dev/null +++ b/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss @@ -0,0 +1,3 @@ +.overlay-scrollbar { + height: 100%; +} diff --git a/frontend/src/components/Uplot/Uplot.tsx b/frontend/src/components/Uplot/Uplot.tsx index 05f050a87c..af6e28ddf3 100644 --- a/frontend/src/components/Uplot/Uplot.tsx +++ b/frontend/src/components/Uplot/Uplot.tsx @@ -1,6 +1,7 @@ /* eslint-disable sonarjs/cognitive-complexity */ import './Uplot.styles.scss'; +import * as Sentry from '@sentry/react'; import { Typography } from 'antd'; import { ToggleGraphProps } from 'components/Graph/types'; import { LineChart } from 'lucide-react'; @@ -13,7 +14,6 @@ import { useImperativeHandle, useRef, } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; import UPlot from 'uplot'; import { dataMatch, optionsUpdateState } from './utils'; @@ -139,7 +139,7 @@ const Uplot = forwardRef( } return ( - + }>
{data && data[0] && data[0]?.length === 0 ? (
@@ -147,7 +147,7 @@ const Uplot = forwardRef(
) : null}
-
+ ); }, ); diff --git a/frontend/src/components/ValueGraph/index.tsx b/frontend/src/components/ValueGraph/index.tsx index 6f2eaa8de1..f0ee1e08d1 100644 --- a/frontend/src/components/ValueGraph/index.tsx +++ b/frontend/src/components/ValueGraph/index.tsx @@ -49,7 +49,10 @@ function ValueGraph({ } > - +
)} diff --git a/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx b/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx new file mode 100644 index 0000000000..4f0e905fc2 --- /dev/null +++ b/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx @@ -0,0 +1,37 @@ +import './virtuosoOverlayScrollbar.scss'; + +import useInitializeOverlayScrollbar from 'hooks/useInitializeOverlayScrollbar/useInitializeOverlayScrollbar'; +import { PartialOptions } from 'overlayscrollbars'; +import React, { CSSProperties, ReactElement } from 'react'; + +interface VirtuosoOverlayScrollbarProps { + children: ReactElement; + style?: CSSProperties; + options: PartialOptions; +} + +export default function VirtuosoOverlayScrollbar({ + children, + style, + options, +}: VirtuosoOverlayScrollbarProps): JSX.Element { + const { rootRef, setScroller } = useInitializeOverlayScrollbar(options); + + const enhancedChild = React.cloneElement(children, { + scrollerRef: setScroller, + 'data-overlayscrollbars-initialize': true, + }); + + return ( +
+ {enhancedChild} +
+ ); +} + +VirtuosoOverlayScrollbar.defaultProps = { style: {} }; diff --git a/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss b/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss new file mode 100644 index 0000000000..728bd1e8c3 --- /dev/null +++ b/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss @@ -0,0 +1,5 @@ +.overlay-scroll-wrapper { + height: 100%; + width: 100%; + overflow: auto; +} diff --git a/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx b/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx deleted file mode 100644 index 2a4b07aa22..0000000000 --- a/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import './FacingIssueBtn.style.scss'; - -import { Button, Tooltip } from 'antd'; -import logEvent from 'api/common/logEvent'; -import cx from 'classnames'; -import { FeatureKeys } from 'constants/features'; -import useFeatureFlags from 'hooks/useFeatureFlag'; -import { defaultTo } from 'lodash-es'; -import { HelpCircle } from 'lucide-react'; -import { isCloudUser } from 'utils/app'; - -export interface FacingIssueBtnProps { - eventName: string; - attributes: Record; - message?: string; - buttonText?: string; - className?: string; - onHoverText?: string; -} - -function FacingIssueBtn({ - attributes, - eventName, - message = '', - buttonText = '', - className = '', - onHoverText = '', -}: FacingIssueBtnProps): JSX.Element | null { - const handleFacingIssuesClick = (): void => { - logEvent(eventName, attributes); - - if (window.Intercom) { - window.Intercom('showNewMessage', defaultTo(message, '')); - } - }; - - const isChatSupportEnabled = useFeatureFlags(FeatureKeys.CHAT_SUPPORT)?.active; - const isCloudUserVal = isCloudUser(); - - return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future -
- - - -
- ) : null; -} - -FacingIssueBtn.defaultProps = { - message: '', - buttonText: '', - className: '', - onHoverText: '', -}; - -export default FacingIssueBtn; diff --git a/frontend/src/constants/env.ts b/frontend/src/constants/env.ts index 2c5230dfcc..cf75739eff 100644 --- a/frontend/src/constants/env.ts +++ b/frontend/src/constants/env.ts @@ -3,4 +3,5 @@ export const ENVIRONMENT = { process?.env?.FRONTEND_API_ENDPOINT || process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') || '', + wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '', }; diff --git a/frontend/src/constants/features.ts b/frontend/src/constants/features.ts index bb905d0d69..769522455d 100644 --- a/frontend/src/constants/features.ts +++ b/frontend/src/constants/features.ts @@ -19,6 +19,7 @@ export enum FeatureKeys { OSS = 'OSS', ONBOARDING = 'ONBOARDING', CHAT_SUPPORT = 'CHAT_SUPPORT', - PLANNED_MAINTENANCE = 'PLANNED_MAINTENANCE', GATEWAY = 'GATEWAY', + PREMIUM_SUPPORT = 'PREMIUM_SUPPORT', + QUERY_BUILDER_SEARCH_V2 = 'QUERY_BUILDER_SEARCH_V2', } diff --git a/frontend/src/constants/global.ts b/frontend/src/constants/global.ts index 42fb29720b..dfa096470d 100644 --- a/frontend/src/constants/global.ts +++ b/frontend/src/constants/global.ts @@ -1,4 +1,17 @@ +import { ManipulateType } from 'dayjs'; + const MAX_RPS_LIMIT = 100; export { MAX_RPS_LIMIT }; export const LEGEND = 'legend'; + +export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', + HOUR: 'hour', + MINUTE: 'minute', + SECOND: 'second', + MILLISECOND: 'millisecond', +}; diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index c7e8b81179..4e6859a2dd 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,4 +19,6 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS', + SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/constants/panelTypes.ts b/frontend/src/constants/panelTypes.ts index 7476b20783..8892f0fcbe 100644 --- a/frontend/src/constants/panelTypes.ts +++ b/frontend/src/constants/panelTypes.ts @@ -40,4 +40,5 @@ export const getComponentForPanelType = ( export const AVAILABLE_EXPORT_PANEL_TYPES = [ PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, + PANEL_TYPES.LIST, ]; diff --git a/frontend/src/constants/query.ts b/frontend/src/constants/query.ts index 9b731ca089..3ee0a39634 100644 --- a/frontend/src/constants/query.ts +++ b/frontend/src/constants/query.ts @@ -32,4 +32,8 @@ export enum QueryParams { relativeTime = 'relativeTime', alertType = 'alertType', ruleId = 'ruleId', + consumerGrp = 'consumerGrp', + topic = 'topic', + partition = 'partition', + selectedTimelineQuery = 'selectedTimelineQuery', } diff --git a/frontend/src/constants/queryBuilder.ts b/frontend/src/constants/queryBuilder.ts index 7b7b464b3e..5fe7112796 100644 --- a/frontend/src/constants/queryBuilder.ts +++ b/frontend/src/constants/queryBuilder.ts @@ -52,7 +52,7 @@ export const selectValueDivider = '__'; export const baseAutoCompleteIdKeysOrder: (keyof Omit< BaseAutocompleteData, - 'id' | 'isJSON' + 'id' | 'isJSON' | 'isIndexed' >)[] = ['key', 'dataType', 'type', 'isColumn']; export const autocompleteType: Record = { @@ -71,6 +71,7 @@ export const alphabet: string[] = alpha.map((str) => String.fromCharCode(str)); export enum QueryBuilderKeys { GET_AGGREGATE_ATTRIBUTE = 'GET_AGGREGATE_ATTRIBUTE', GET_AGGREGATE_KEYS = 'GET_AGGREGATE_KEYS', + GET_ATTRIBUTE_SUGGESTIONS = 'GET_ATTRIBUTE_SUGGESTIONS', } export const mapOfOperators = { diff --git a/frontend/src/constants/queryFunctionOptions.ts b/frontend/src/constants/queryFunctionOptions.ts index 4aa6332d67..4a7b3b0413 100644 --- a/frontend/src/constants/queryFunctionOptions.ts +++ b/frontend/src/constants/queryFunctionOptions.ts @@ -23,6 +23,10 @@ export const metricQueryFunctionOptions: SelectOption[] = [ value: QueryFunctionsTypes.ABSOLUTE, label: 'Absolute', }, + { + value: QueryFunctionsTypes.RUNNING_DIFF, + label: 'Running Diff', + }, { value: QueryFunctionsTypes.LOG_2, label: 'Log2', @@ -103,6 +107,9 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = { absolute: { showInput: false, }, + runningDiff: { + showInput: false, + }, log2: { showInput: false, }, diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 63fc205d81..ec2353abbf 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -8,4 +8,14 @@ export const REACT_QUERY_KEY = { GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS', DELETE_DASHBOARD: 'DELETE_DASHBOARD', LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW', + ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS', + ALERT_RULE_STATS: 'ALERT_RULE_STATS', + ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS', + ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE', + ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH', + GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS', + TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE', + GET_ALL_ALLERTS: 'GET_ALL_ALLERTS', + REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE', + DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 243bdd0bba..b4f43ee684 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -13,6 +13,7 @@ const ROUTES = { GET_STARTED_INFRASTRUCTURE_MONITORING: '/get-started/infrastructure-monitoring', GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring', + GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring', USAGE_EXPLORER: '/usage-explorer', APPLICATION: '/services', ALL_DASHBOARD: '/dashboard', @@ -21,6 +22,8 @@ const ROUTES = { EDIT_ALERTS: '/alerts/edit', LIST_ALL_ALERT: '/alerts', ALERTS_NEW: '/alerts/new', + ALERT_HISTORY: '/alerts/history', + ALERT_OVERVIEW: '/alerts/overview', ALL_CHANNELS: '/settings/channels', CHANNELS_NEW: '/settings/channels/new', CHANNELS_EDIT: '/settings/channels/:id', @@ -53,6 +56,8 @@ const ROUTES = { WORKSPACE_LOCKED: '/workspace-locked', SHORTCUTS: '/shortcuts', INTEGRATIONS: '/integrations', + MESSAGING_QUEUES: '/messaging-queues', + MESSAGING_QUEUES_DETAIL: '/messaging-queues/detail', } as const; export default ROUTES; diff --git a/frontend/src/constants/shortcuts/globalShortcuts.ts b/frontend/src/constants/shortcuts/globalShortcuts.ts index 81420fc830..4ab7752fac 100644 --- a/frontend/src/constants/shortcuts/globalShortcuts.ts +++ b/frontend/src/constants/shortcuts/globalShortcuts.ts @@ -9,6 +9,7 @@ export const GlobalShortcuts = { NavigateToDashboards: 'd+shift', NavigateToAlerts: 'a+shift', NavigateToExceptions: 'e+shift', + NavigateToMessagingQueues: 'm+shift', }; export const GlobalShortcutsName = { @@ -19,6 +20,7 @@ export const GlobalShortcutsName = { NavigateToDashboards: 'shift+d', NavigateToAlerts: 'shift+a', NavigateToExceptions: 'shift+e', + NavigateToMessagingQueues: 'shift+m', }; export const GlobalShortcutsDescription = { @@ -29,4 +31,5 @@ export const GlobalShortcutsDescription = { NavigateToDashboards: 'Navigate to dashboards page', NavigateToAlerts: 'Navigate to alerts page', NavigateToExceptions: 'Navigate to Exceptions page', + NavigateToMessagingQueues: 'Navigate to Messaging Queues page', }; diff --git a/frontend/src/constants/shortcuts/logsExplorerShortcuts.ts b/frontend/src/constants/shortcuts/logsExplorerShortcuts.ts index 33c2b4061f..68331a4c2d 100644 --- a/frontend/src/constants/shortcuts/logsExplorerShortcuts.ts +++ b/frontend/src/constants/shortcuts/logsExplorerShortcuts.ts @@ -4,6 +4,7 @@ const userOS = getUserOperatingSystem(); export const LogsExplorerShortcuts = { StageAndRunQuery: 'enter+meta', FocusTheSearchBar: 's', + ShowAllFilters: '/+meta', }; export const LogsExplorerShortcutsName = { @@ -11,9 +12,11 @@ export const LogsExplorerShortcutsName = { userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl' }+enter`, FocusTheSearchBar: 's', + ShowAllFilters: `${userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl'}+/`, }; export const LogsExplorerShortcutsDescription = { StageAndRunQuery: 'Stage and Run the current query', FocusTheSearchBar: 'Shift the focus to the last query filter bar', + ShowAllFilters: 'Toggle all filters in the filters dropdown', }; diff --git a/frontend/src/container/AlertHistory/AlertHistory.styles.scss b/frontend/src/container/AlertHistory/AlertHistory.styles.scss new file mode 100644 index 0000000000..39fce3ca29 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.styles.scss @@ -0,0 +1,5 @@ +.alert-history { + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/frontend/src/container/AlertHistory/AlertHistory.tsx b/frontend/src/container/AlertHistory/AlertHistory.tsx new file mode 100644 index 0000000000..0776cfcebb --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.tsx @@ -0,0 +1,22 @@ +import './AlertHistory.styles.scss'; + +import { useState } from 'react'; + +import Statistics from './Statistics/Statistics'; +import Timeline from './Timeline/Timeline'; + +function AlertHistory(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); + + return ( +
+ + +
+ ); +} + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss new file mode 100644 index 0000000000..144996ba38 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss @@ -0,0 +1,21 @@ +.alert-popover-trigger-action { + cursor: pointer; +} + +.alert-history-popover { + .ant-popover-inner { + border: 1px solid var(--bg-slate-400); + + .lightMode & { + background: var(--bg-vanilla-100) !important; + border: 1px solid var(--bg-vanilla-300); + } + } + .ant-popover-arrow { + &::before { + .lightMode & { + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx new file mode 100644 index 0000000000..d70da07903 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx @@ -0,0 +1,115 @@ +import './AlertPopover.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import LogsIcon from 'assets/AlertHistory/LogsIcon'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { DraftingCompass } from 'lucide-react'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +type Props = { + children: React.ReactNode; + relatedTracesLink?: string; + relatedLogsLink?: string; +}; + +function PopoverContent({ + relatedTracesLink, + relatedLogsLink, +}: { + relatedTracesLink?: Props['relatedTracesLink']; + relatedLogsLink?: Props['relatedLogsLink']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( +
+ {!!relatedLogsLink && ( + +
+ +
+
View Logs
+ + )} + {!!relatedTracesLink && ( + +
+ +
+
View Traces
+ + )} +
+ ); +} +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { + return ( +
+ + } + trigger="click" + > + {children} + +
+ ); +} + +AlertPopover.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +type ConditionalAlertPopoverProps = { + relatedTracesLink: string; + relatedLogsLink: string; + children: React.ReactNode; +}; +export function ConditionalAlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: ConditionalAlertPopoverProps): JSX.Element { + if (relatedTracesLink || relatedLogsLink) { + return ( + + {children} + + ); + } + return
{children}
; +} +export default AlertPopover; diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx new file mode 100644 index 0000000000..f55c4385ce --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -0,0 +1,28 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; +import { formatTime } from 'utils/timeUtils'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; + timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default AverageResolutionCard; diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss new file mode 100644 index 0000000000..cc0a5b1b43 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss @@ -0,0 +1,14 @@ +.statistics { + display: flex; + justify-content: space-between; + height: 280px; + border: 1px solid var(--bg-slate-500); + border-radius: 4px; + margin: 0 16px; +} + +.lightMode { + .statistics { + border: 1px solid var(--bg-vanilla-300); + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx new file mode 100644 index 0000000000..7158e0c069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -0,0 +1,23 @@ +import './Statistics.styles.scss'; + +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; + +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + return ( +
+ + +
+ ); +} + +export default Statistics; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss new file mode 100644 index 0000000000..bb9d3c3e72 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss @@ -0,0 +1,112 @@ +.stats-card { + width: 21.7%; + border-right: 1px solid var(--bg-slate-500); + padding: 9px 12px 13px; + + &--empty { + justify-content: normal; + } + + &__title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + .title { + text-transform: uppercase; + font-size: 13px; + line-height: 22px; + color: var(--bg-vanilla-400); + font-weight: 500; + } + .duration-indicator { + display: flex; + align-items: center; + gap: 4px; + .icon { + display: flex; + align-self: center; + } + .text { + text-transform: uppercase; + color: var(--text-slate-200); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.48px; + } + } + } + &__stats { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 4px; + .count-label { + color: var(--text-vanilla-100); + font-family: 'Geist Mono'; + font-size: 24px; + line-height: 36px; + } + } + &__graph { + margin-top: 80px; + + .graph { + width: 100%; + height: 72px; + } + } +} + +.change-percentage { + width: max-content; + display: flex; + padding: 4px 8px; + border-radius: 20px; + align-items: center; + gap: 4px; + + &--success { + background: rgba(37, 225, 146, 0.1); + color: var(--bg-forest-500); + } + &--error { + background: rgba(229, 72, 77, 0.1); + color: var(--bg-cherry-500); + } + &--no-previous-data { + color: var(--text-robin-500); + background: rgba(78, 116, 248, 0.1); + padding: 4px 16px; + } + &__icon { + display: flex; + align-self: center; + } + &__label { + font-size: 12px; + font-weight: 500; + line-height: 16px; + } +} + +.lightMode { + .stats-card { + border-color: var(--bg-vanilla-300); + &__title-wrapper { + .title { + color: var(--text-ink-400); + } + .duration-indicator { + .text { + color: var(--text-ink-200); + } + } + } + &__stats { + .count-label { + color: var(--text-ink-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx new file mode 100644 index 0000000000..f204579f93 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -0,0 +1,158 @@ +import './StatsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react'; +import { AlertRuleStats } from 'types/api/alerts/def'; +import { calculateChange } from 'utils/calculateChange'; + +import StatsGraph from './StatsGraph/StatsGraph'; +import { + convertTimestampToLocaleDateString, + extractDayFromTimestamp, +} from './utils'; + +type ChangePercentageProps = { + percentage: number; + direction: number; + duration: string | null; +}; +function ChangePercentage({ + percentage, + direction, + duration, +}: ChangePercentageProps): JSX.Element { + if (direction > 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + + return ( +
+
no previous data
+
+ ); +} + +type StatsCardProps = { + totalCurrentCount?: number; + totalPastCount?: number; + title: string; + isEmpty?: boolean; + emptyMessage?: string; + displayValue?: string | number; + timeSeries?: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function StatsCard({ + displayValue, + totalCurrentCount, + totalPastCount, + title, + isEmpty, + emptyMessage, + timeSeries = [], +}: StatsCardProps): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { changePercentage, changeDirection } = calculateChange( + totalCurrentCount, + totalPastCount, + ); + + const startTime = urlQuery.get(QueryParams.startTime); + const endTime = urlQuery.get(QueryParams.endTime); + + let displayTime = relativeTime; + + if (!displayTime && startTime && endTime) { + const formattedStartDate = extractDayFromTimestamp(startTime); + const formattedEndDate = extractDayFromTimestamp(endTime); + displayTime = `${formattedStartDate} to ${formattedEndDate}`; + } + + if (!displayTime) { + displayTime = ''; + } + const formattedStartTimeForTooltip = convertTimestampToLocaleDateString( + startTime, + ); + const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime); + + return ( +
+
+
{title}
+
+
+ +
+ {relativeTime ? ( +
{displayTime}
+ ) : ( + +
{displayTime}
+
+ )} +
+
+ +
+
+ {isEmpty ? emptyMessage : displayValue || totalCurrentCount} +
+ + +
+ +
+
+ {!isEmpty && timeSeries.length > 1 && ( + + )} +
+
+
+ ); +} + +StatsCard.defaultProps = { + totalCurrentCount: 0, + totalPastCount: 0, + isEmpty: false, + emptyMessage: 'No Data', + displayValue: '', + timeSeries: [], +}; + +export default StatsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx new file mode 100644 index 0000000000..26c381d706 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx @@ -0,0 +1,90 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { useMemo, useRef } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +type Props = { + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; + changeDirection: number; +}; + +const getStyle = ( + changeDirection: number, +): { stroke: string; fill: string } => { + if (changeDirection === 0) { + return { + stroke: Color.BG_ROBIN_500, + fill: 'rgba(78, 116, 248, 0.20)', + }; + } + if (changeDirection > 0) { + return { + stroke: Color.BG_FOREST_500, + fill: 'rgba(37, 225, 146, 0.20)', + }; + } + return { + stroke: Color.BG_CHERRY_500, + fill: ' rgba(229, 72, 77, 0.20)', + }; +}; + +function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element { + const { xData, yData } = useMemo( + () => ({ + xData: timeSeries.map((item) => item.timestamp), + yData: timeSeries.map((item) => Number(item.value)), + }), + [timeSeries], + ); + + const graphRef = useRef(null); + + const containerDimensions = useResizeObserver(graphRef); + + const options: uPlot.Options = useMemo( + () => ({ + width: containerDimensions.width, + height: containerDimensions.height, + + legend: { + show: false, + }, + cursor: { + x: false, + y: false, + drag: { + x: false, + y: false, + }, + }, + padding: [0, 0, 2, 0], + series: [ + {}, + { + ...getStyle(changeDirection), + points: { + show: false, + }, + width: 1.4, + }, + ], + axes: [ + { show: false }, + { + show: false, + }, + ], + }), + [changeDirection, containerDimensions.height, containerDimensions.width], + ); + + return ( +
+ +
+ ); +} + +export default StatsGraph; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts new file mode 100644 index 0000000000..a2584aad37 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts @@ -0,0 +1,12 @@ +export const extractDayFromTimestamp = (timestamp: string | null): string => { + if (!timestamp) return ''; + const date = new Date(parseInt(timestamp, 10)); + return date.getDate().toString(); +}; + +export const convertTimestampToLocaleDateString = ( + timestamp: string | null, +): string => { + if (!timestamp) return ''; + return new Date(parseInt(timestamp, 10)).toLocaleString(); +}; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx new file mode 100644 index 0000000000..e8859131df --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -0,0 +1,102 @@ +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { useEffect } from 'react'; + +import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import StatsCard from '../StatsCard/StatsCard'; +import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: number | string, + totalPastTriggers: number | string, +): boolean => + (Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) || + Number(totalCurrentTriggers) > 0; + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: number | string, + pastAvgResolutionTime: number | string, +): boolean => + (Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) || + Number(currentAvgResolutionTime) > 0; + +type StatsCardsRendererProps = { + setTotalCurrentTriggers: (value: number) => void; +}; + +// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title +function StatsCardsRenderer({ + setTotalCurrentTriggers, +}: StatsCardsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsStats(); + + useEffect(() => { + if (data?.payload?.data?.totalCurrentTriggers !== undefined) { + setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers); + } + }, [data, setTotalCurrentTriggers]); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + currentAvgResolutionTimeSeries, + currentTriggersSeries, + } = data; + + return ( + <> + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( + + ) : ( + + )} + + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) ? ( + + ) : ( + + )} + + ); + }} + + ); +} + +export default StatsCardsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss new file mode 100644 index 0000000000..0b0995fb3e --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss @@ -0,0 +1,213 @@ +.top-contributors-card { + width: 56.6%; + overflow: hidden; + + &--view-all { + width: auto; + } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + + border-bottom: 1px solid var(--bg-slate-500); + .title { + color: var(--text-vanilla-400); + font-size: 13px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.52px; + text-transform: uppercase; + } + .view-all { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } + + .label { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + } + .icon { + display: flex; + } + } + } + .contributors-row { + height: 80px; + } + &__content { + .ant-table { + &-cell { + padding: 12px !important; + } + } + .contributors-row { + background: var(--bg-ink-500); + + td { + border: none !important; + } + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + .total-contribution { + color: var(--text-robin-500); + font-family: 'Geist Mono'; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.06px; + padding: 4px 8px; + background: rgba(78, 116, 248, 0.1); + border-radius: 50px; + width: max-content; + } + } + .empty-content { + margin: 16px 12px; + padding: 40px 45px; + display: flex; + flex-direction: column; + gap: 12px; + border: 1px dashed var(--bg-slate-500); + border-radius: 6px; + + &__icon { + font-family: Inter; + font-size: 20px; + line-height: 26px; + letter-spacing: -0.103px; + } + &__text { + color: var(--text-vanilla-400); + line-height: 18px; + .bold-text { + color: var(--text-vanilla-100); + font-weight: 500; + } + } + &__button-wrapper { + margin-top: 12px; + .configure-alert-rule-button { + padding: 8px 16px; + border-radius: 2px; + background: var(--bg-slate-400); + border-width: 0; + color: var(--text-vanilla-100); + line-height: 24px; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + } + } + } +} + +.ant-popover-inner:has(.contributor-row-popover-buttons) { + padding: 0 !important; +} +.contributor-row-popover-buttons { + display: flex; + flex-direction: column; + + &__button { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 15px; + color: var(--text-vanilla-400); + font-size: 14px; + letter-spacing: 0.14px; + width: 160px; + cursor: pointer; + + .text, + .icon { + color: var(--text-vanilla-100); + + .lightMode & { + color: var(--text-ink-500); + } + } + + &:hover { + background: var(--bg-slate-400); + + .text, + .icon { + color: var(--text-vanilla-100); + + .lightMode & { + color: var(--text-ink-500); + } + } + } + + .icon { + display: flex; + } + + .lightMode & { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-400); + } + } +} + +.view-all-drawer { + border-radius: 4px; +} + +.lightMode { + .ant-table { + background: inherit; + } + + .top-contributors-card { + &__header { + border-color: var(--bg-vanilla-300); + .title { + color: var(--text-ink-400); + } + .view-all { + .label { + color: var(--text-ink-400); + } + } + } + &__content { + .contributors-row { + background: inherit; + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + .empty-content { + border-color: var(--bg-vanilla-300); + &__text { + color: var(--text-ink-400); + .bold-text { + color: var(--text-ink-500); + } + } + &__button-wrapper { + .configure-alert-rule-button { + background: var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx new file mode 100644 index 0000000000..d3cd0bb756 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -0,0 +1,84 @@ +import './TopContributorsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import history from 'lib/history'; +import { ArrowRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import TopContributorsContent from './TopContributorsContent'; +import { TopContributorsCardProps } from './types'; +import ViewAllDrawer from './ViewAllDrawer'; + +function TopContributorsCard({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors'); + + const [isViewAllVisible, setIsViewAllVisible] = useState( + !!viewAllTopContributorsParam ?? false, + ); + + const isDarkMode = useIsDarkMode(); + + const toggleViewAllParam = (isOpen: boolean): void => { + if (isOpen) { + searchParams.set('viewAllTopContributors', 'true'); + } else { + searchParams.delete('viewAllTopContributors'); + } + }; + + const toggleViewAllDrawer = (): void => { + setIsViewAllVisible((prev) => { + const newState = !prev; + + toggleViewAllParam(newState); + + return newState; + }); + history.push({ search: searchParams.toString() }); + }; + + return ( + <> +
+
+
top contributors
+ {topContributorsData.length > 3 && ( + + )} +
+ + +
+ {isViewAllVisible && ( + + )} + + ); +} + +export default TopContributorsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx new file mode 100644 index 0000000000..b458871f71 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,32 @@ +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Top contributors highlight the most frequently triggering group-by + attributes in multi-dimensional alerts +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export default TopContributorsContent; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx new file mode 100644 index 0000000000..85857605f8 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,87 @@ +import { Color } from '@signozhq/design-tokens'; +import { Progress, Table } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +function TopContributorsRows({ + topContributors, + totalCurrentTriggers, +}: { + topContributors: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const columns: ColumnsType = [ + { + title: 'labels', + dataIndex: 'labels', + key: 'labels', + width: '51%', + render: ( + labels: AlertRuleTopContributors['labels'], + record, + ): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'progressBar', + dataIndex: 'count', + key: 'progressBar', + width: '39%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + + + + ), + }, + { + title: 'count', + dataIndex: 'count', + key: 'count', + width: '10%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + +
+ {count}/{totalCurrentTriggers} +
+
+ ), + }, + ]; + + return ( +
`top-contributor-${row.fingerprint}`} + columns={columns} + showHeader={false} + dataSource={topContributors} + pagination={ + topContributors.length > 10 ? { showTotal: PaginationInfoText } : false + } + /> + ); +} + +export default TopContributorsRows; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx new file mode 100644 index 0000000000..1d49c87afd --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx @@ -0,0 +1,46 @@ +import { Color } from '@signozhq/design-tokens'; +import { Drawer } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +import TopContributorsRows from './TopContributorsRows'; + +function ViewAllDrawer({ + isViewAllVisible, + toggleViewAllDrawer, + totalCurrentTriggers, + topContributorsData, +}: { + isViewAllVisible: boolean; + toggleViewAllDrawer: () => void; + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + +
+
+ +
+
+
+ ); +} + +export default ViewAllDrawer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts new file mode 100644 index 0000000000..f44d2ded99 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts @@ -0,0 +1,6 @@ +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +export type TopContributorsCardProps = { + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx new file mode 100644 index 0000000000..b773579ca0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx @@ -0,0 +1,42 @@ +import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +import TopContributorsCard from '../TopContributorsCard/TopContributorsCard'; + +type TopContributorsRendererProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; + +function TopContributorsRenderer({ + totalCurrentTriggers, +}: TopContributorsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTopContributors(); + const response = data?.payload?.data; + + // TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all + return ( + + {(topContributorsData): JSX.Element => ( + + )} + + ); +} + +export default TopContributorsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx new file mode 100644 index 0000000000..0e4f412894 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -0,0 +1,26 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default TotalTriggeredCard; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss new file mode 100644 index 0000000000..3ea30fe25a --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss @@ -0,0 +1,52 @@ +.timeline-graph { + display: flex; + flex-direction: column; + gap: 24px; + background: var(--bg-ink-400); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + height: 150px; + + &__title { + width: max-content; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #1d212d; + background: rgba(29, 33, 45, 0.5); + color: #ebebeb; + font-size: 12px; + line-height: 18px; + letter-spacing: -0.06px; + } + &__chart { + .chart-placeholder { + width: 100%; + height: 52px; + background: rgba(255, 255, 255, 0.1215686275); + display: flex; + align-items: center; + justify-content: center; + .chart-icon { + font-size: 2rem; + } + } + } +} + +.lightMode { + .timeline-graph { + background: var(--bg-vanilla-200); + border-color: var(--bg-vanilla-300); + &__title { + background: var(--bg-vanilla-100); + color: var(--text-ink-400); + border-color: var(--bg-vanilla-300); + } + &__chart { + .chart-placeholder { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx new file mode 100644 index 0000000000..5adf1c481a --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -0,0 +1,181 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin'; +import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; +import { useMemo, useRef } from 'react'; +import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def'; +import uPlot, { AlignedData } from 'uplot'; + +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; + +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; + +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo(() => { + if (!data?.length) { + return [[], []]; + } + + // add an entry for the end time of the last entry to make sure the graph displays all the data + + const timestamps = [ + ...data.map((item) => item.start / 1000), + data[data.length - 1].end / 1000, // end value of last entry + ]; + + const states = [ + ...data.map((item) => ALERT_STATUS[item.state]), + ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry + ]; + + return [timestamps, states]; + }, [data]); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 85, + cursor: { show: false }, + + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + legend: { + show: false, + }, + padding: [null, 0, null, 0], + series: [ + { + label: 'Time', + }, + { + label: 'States', + }, + ], + plugins: + transformedData?.length > 1 + ? [ + timelinePlugin({ + count: transformedData.length - 1, + ...TIMELINE_OPTIONS, + }), + ] + : [], + }), + [width, isDarkMode, transformedData], + ); + return ; +} + +const transformVerticalTimelineGraph = (data: any[]): any => [ + data.map((item: { timestamp: any }) => item.timestamp), + Array(data.length).fill(0), + Array(data.length).fill(10), + Array(data.length).fill([0, 1, 2, 3, 4, 5]), + data.map((item: { value: number }) => { + const count = Math.floor(item.value / 10); + return [...Array(count).fill(1), 2]; + }), +]; + +const datatest: any[] = []; +const now = Math.floor(Date.now() / 1000); // current timestamp in seconds +const oneDay = 24 * 60 * 60; // one day in seconds + +for (let i = 0; i < 90; i++) { + const timestamp = now - i * oneDay; + const startOfDay = timestamp - (timestamp % oneDay); + datatest.push({ + timestamp: startOfDay, + value: Math.floor(Math.random() * 30) + 1, + }); +} + +function VerticalTimelineGraph({ + isDarkMode, + width, +}: { + width: number; + isDarkMode: boolean; +}): JSX.Element { + const transformedData = useMemo( + () => transformVerticalTimelineGraph(datatest), + [], + ); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 90, + plugins: [heatmapPlugin()], + cursor: { show: false }, + legend: { + show: false, + }, + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + series: [ + {}, + { + paths: (): null => null, + points: { show: false }, + }, + { + paths: (): null => null, + points: { show: false }, + }, + ], + }), + [isDarkMode, width], + ); + return ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +} + +export default Graph; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts new file mode 100644 index 0000000000..b56499a0d0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,33 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + inactive: 1, + normal: 1, + 'no-data': 2, + disabled: 3, + muted: 4, +}; + +export const STATE_VS_COLOR: { + [key: string]: { stroke: string; fill: string }; +}[] = [ + {}, + { + 0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 }, + 1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 }, + 2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 }, + 3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 }, + 4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 }, + }, +]; + +export const TIMELINE_OPTIONS = { + mode: 1, + fill: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].fill, + stroke: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].stroke, + laneWidthOption: 0.3, + showGrid: false, +}; diff --git a/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 0000000000..05690a9041 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,67 @@ +import '../Graph/Graph.styles.scss'; + +import useUrlQuery from 'hooks/useUrlQuery'; +import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import Graph from '../Graph/Graph'; + +function GraphWrapper({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineGraphData(); + + // TODO(shaheer): uncomment when the API is ready for + // const { startTime } = useAlertHistoryQueryParams(); + + // const [isVerticalGraph, setIsVerticalGraph] = useState(false); + + // useEffect(() => { + // const checkVerticalGraph = (): void => { + // if (startTime) { + // const startTimeDate = dayjs(Number(startTime)); + // const twentyFourHoursAgo = dayjs().subtract( + // HORIZONTAL_GRAPH_HOURS_THRESHOLD, + // DAYJS_MANIPULATE_TYPES.HOUR, + // ); + + // setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo)); + // } + // }; + + // checkVerticalGraph(); + // }, [startTime]); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss new file mode 100644 index 0000000000..26e2266ef6 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss @@ -0,0 +1,123 @@ +.timeline-table { + border-top: 1px solid var(--text-slate-500); + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + min-height: 600px; + .ant-table { + background: var(--bg-ink-500); + &-cell { + padding: 12px 16px !important; + vertical-align: baseline; + &::before { + display: none; + } + } + &-thead > tr > th { + border-color: var(--bg-slate-500); + background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; + padding: 12px 16px 8px !important; + } + &-tbody > tr > td { + border: none; + } + } + + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + line-height: 18px; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } + } + .alert-rule { + &-value, + &__created-at { + font-size: 14px; + color: var(--text-vanilla-400); + } + &-value { + font-weight: 500; + line-height: 20px; + } + &__created-at { + line-height: 18px; + letter-spacing: -0.07px; + } + } + .ant-table.ant-table-middle { + border-bottom: 1px solid var(--bg-slate-500); + border-left: 1px solid var(--bg-slate-500); + border-right: 1px solid var(--bg-slate-500); + + border-radius: 6px; + } + .ant-pagination-item { + &-active { + display: flex; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 2px; + background: var(--bg-robin-500); + & > a { + color: var(--text-ink-500); + line-height: 20px; + font-weight: 500; + } + } + } + .alert-history-label-search { + .ant-select-selector { + border: none; + } + } +} + +.lightMode { + .timeline-table { + border-color: var(--bg-vanilla-300); + + .ant-table { + background: var(--bg-vanilla-100); + &-thead { + & > tr > th { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + } + &.ant-table-middle { + border-color: var(--bg-vanilla-300); + } + } + .alert-history-label-search { + .ant-select-selector { + background: var(--bg-vanilla-200); + } + } + + .alert-rule { + &-value, + &-created-at { + color: var(--text-ink-400); + } + } + .ant-pagination-item { + &-active > a { + color: var(--text-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx new file mode 100644 index 0000000000..f3144b88e6 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -0,0 +1,56 @@ +import './Table.styles.scss'; + +import { Table } from 'antd'; +import { + useGetAlertRuleDetailsTimelineTable, + useTimelineTable, +} from 'pages/AlertDetails/hooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { timelineTableColumns } from './useTimelineTable'; + +function TimelineTable(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + const { timelineData, totalItems } = useMemo(() => { + const response = data?.payload?.data; + return { + timelineData: response?.items, + totalItems: response?.total, + }; + }, [data?.payload?.data]); + + const { paginationConfig, onChangeHandler } = useTimelineTable({ + totalItems: totalItems ?? 0, + }); + + const { t } = useTranslation('common'); + + if (isError || !isValidRuleId || !ruleId) { + return
{t('something_went_wrong')}
; + } + + return ( +
+
`${row.fingerprint}-${row.value}-${row.unixMilli}`} + columns={timelineTableColumns()} + dataSource={timelineData} + pagination={paginationConfig} + size="middle" + onChange={onChangeHandler} + loading={isLoading || isRefetching} + /> + + ); +} + +export default TimelineTable; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts new file mode 100644 index 0000000000..badf649867 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -0,0 +1,9 @@ +import { + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, +} from 'types/api/alerts/def'; + +export type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; + totalItems: AlertRuleTimelineTableResponsePayload['data']['total']; +}; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx new file mode 100644 index 0000000000..1eb43fc417 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,54 @@ +import { EllipsisOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +export const timelineTableColumns = (): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + sorter: true, + width: 140, + render: (value): JSX.Element => ( +
+ +
+ ), + }, + { + title: 'LABELS', + dataIndex: 'labels', + render: (labels): JSX.Element => ( +
+ +
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + width: 200, + render: (value): JSX.Element => ( +
{formatEpochTimestamp(value)}
+ ), + }, + { + title: 'ACTIONS', + width: 140, + align: 'right', + render: (record): JSX.Element => ( + + + + ), + }, +]; diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..c153ba65fc --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,32 @@ +.timeline-tabs-and-filters { + display: flex; + justify-content: space-between; + align-items: center; + .reset-button, + .top-5-contributors { + display: flex; + align-items: center; + gap: 10px; + } + .coming-soon { + display: inline-flex; + padding: 4px 8px; + border-radius: 20px; + border: 1px solid rgba(173, 127, 88, 0.2); + background: rgba(173, 127, 88, 0.1); + justify-content: center; + align-items: center; + gap: 5px; + + &__text { + color: var(--text-sienna-400); + font-size: 10px; + font-weight: 500; + letter-spacing: -0.05px; + line-height: normal; + } + &__icon { + display: flex; + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..515cef1616 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,90 @@ +import './TabsAndFilters.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types'; +import history from 'lib/history'; +import { Info } from 'lucide-react'; +import Tabs2 from 'periscope/components/Tabs2'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +function ComingSoon(): JSX.Element { + return ( +
+
Coming Soon
+
+ +
+
+ ); +} +function TimelineTabs(): JSX.Element { + const tabs = [ + { + value: TimelineTab.OVERALL_STATUS, + label: 'Overall Status', + }, + { + value: TimelineTab.TOP_5_CONTRIBUTORS, + label: ( +
+ Top 5 Contributors + +
+ ), + disabled: true, + }, + ]; + + return ; +} + +function TimelineFilters(): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const initialSelectedTab = useMemo( + () => searchParams.get('timelineFilter') ?? TimelineFilter.ALL, + [searchParams], + ); + + const handleFilter = (value: TimelineFilter): void => { + searchParams.set('timelineFilter', value); + history.push({ search: searchParams.toString() }); + }; + + const tabs = [ + { + value: TimelineFilter.ALL, + label: 'All', + }, + { + value: TimelineFilter.FIRED, + label: 'Fired', + }, + { + value: TimelineFilter.RESOLVED, + label: 'Resolved', + }, + ]; + + return ( + + ); +} + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss new file mode 100644 index 0000000000..1d6b4d7990 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss @@ -0,0 +1,22 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 16px; + + &__title { + color: var(--text-vanilla-100); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.07px; + } +} + +.lightMode { + .timeline { + &__title { + color: var(--text-ink-400); + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx new file mode 100644 index 0000000000..18430f7144 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -0,0 +1,32 @@ +import './Timeline.styles.scss'; + +import GraphWrapper from './GraphWrapper/GraphWrapper'; +import TimelineTable from './Table/Table'; +import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; + +function TimelineTableRenderer(): JSX.Element { + return ; +} + +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + return ( +
+
Timeline
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export default Timeline; diff --git a/frontend/src/container/AlertHistory/Timeline/constants.ts b/frontend/src/container/AlertHistory/Timeline/constants.ts new file mode 100644 index 0000000000..2f1652437f --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/constants.ts @@ -0,0 +1,2 @@ +// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector +export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25; diff --git a/frontend/src/container/AlertHistory/constants.ts b/frontend/src/container/AlertHistory/constants.ts new file mode 100644 index 0000000000..2253a27677 --- /dev/null +++ b/frontend/src/container/AlertHistory/constants.ts @@ -0,0 +1 @@ +export const TIMELINE_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/container/AlertHistory/index.tsx b/frontend/src/container/AlertHistory/index.tsx new file mode 100644 index 0000000000..3a99a130a6 --- /dev/null +++ b/frontend/src/container/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from './AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/types.ts b/frontend/src/container/AlertHistory/types.ts new file mode 100644 index 0000000000..797a557eed --- /dev/null +++ b/frontend/src/container/AlertHistory/types.ts @@ -0,0 +1,15 @@ +export enum AlertDetailsTab { + OVERVIEW = 'OVERVIEW', + HISTORY = 'HISTORY', +} + +export enum TimelineTab { + OVERALL_STATUS = 'OVERALL_STATUS', + TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS', +} + +export enum TimelineFilter { + ALL = 'ALL', + FIRED = 'FIRED', + RESOLVED = 'RESOLVED', +} diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx new file mode 100644 index 0000000000..14dbb70084 --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx @@ -0,0 +1,78 @@ +import AlertChannels from 'container/AllAlertChannels'; +import { allAlertChannels } from 'mocks-server/__mockdata__/alerts'; +import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; + +jest.mock('hooks/useFetch', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + payload: allAlertChannels, + })), +})); + +const successNotification = jest.fn(); +jest.mock('hooks/useNotifications', () => ({ + __esModule: true, + useNotifications: jest.fn(() => ({ + notifications: { + success: successNotification, + error: jest.fn(), + }, + })), +})); + +describe('Alert Channels Settings List page', () => { + beforeEach(() => { + render(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('Should display the Alert Channels page properly', () => { + it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => { + expect(screen.getByText('sending_channels_note')).toBeInTheDocument(); + }); + it('Should check if "New Alert Channel" Button is visble ', () => { + expect(screen.getByText('button_new_channel')).toBeInTheDocument(); + }); + it('Should check if the help icon is visible and displays "tooltip_notification_channels ', async () => { + const helpIcon = screen.getByLabelText('question-circle'); + + fireEvent.mouseOver(helpIcon); + + await waitFor(() => { + const tooltip = screen.getByText('tooltip_notification_channels'); + expect(tooltip).toBeInTheDocument(); + }); + }); + }); + describe('Should check if the channels table is properly displayed', () => { + it('Should check if the table columns are properly displayed', () => { + expect(screen.getByText('column_channel_name')).toBeInTheDocument(); + expect(screen.getByText('column_channel_type')).toBeInTheDocument(); + expect(screen.getByText('column_channel_action')).toBeInTheDocument(); + }); + + it('Should check if the data in the table is displayed properly', () => { + expect(screen.getByText('Dummy-Channel')).toBeInTheDocument(); + expect(screen.getAllByText('slack')[0]).toBeInTheDocument(); + expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Delete')[0]).toBeInTheDocument(); + }); + + it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => { + const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0]; + expect(deleteButton).toBeInTheDocument(); + + act(() => { + fireEvent.click(deleteButton); + }); + + await waitFor(() => { + expect(successNotification).toBeCalledWith({ + message: 'Success', + description: 'channel_delete_success', + }); + }); + }); + }); +}); diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx new file mode 100644 index 0000000000..3d957af104 --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx @@ -0,0 +1,72 @@ +import AlertChannels from 'container/AllAlertChannels'; +import { allAlertChannels } from 'mocks-server/__mockdata__/alerts'; +import { fireEvent, render, screen, waitFor } from 'tests/test-utils'; + +jest.mock('hooks/useFetch', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + payload: allAlertChannels, + })), +})); + +const successNotification = jest.fn(); +jest.mock('hooks/useNotifications', () => ({ + __esModule: true, + useNotifications: jest.fn(() => ({ + notifications: { + success: successNotification, + error: jest.fn(), + }, + })), +})); + +jest.mock('hooks/useComponentPermission', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => [false]), +})); + +describe('Alert Channels Settings List page (Normal User)', () => { + beforeEach(() => { + render(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('Should display the Alert Channels page properly', () => { + it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => { + expect(screen.getByText('sending_channels_note')).toBeInTheDocument(); + }); + + it('Should check if "New Alert Channel" Button is visble and disabled', () => { + const newAlertButton = screen.getByRole('button', { + name: 'plus button_new_channel', + }); + expect(newAlertButton).toBeInTheDocument(); + expect(newAlertButton).toBeDisabled(); + }); + it('Should check if the help icon is visible and displays "tooltip_notification_channels ', async () => { + const helpIcon = screen.getByLabelText('question-circle'); + + fireEvent.mouseOver(helpIcon); + + await waitFor(() => { + const tooltip = screen.getByText('tooltip_notification_channels'); + expect(tooltip).toBeInTheDocument(); + }); + }); + }); + describe('Should check if the channels table is properly displayed', () => { + it('Should check if the table columns are properly displayed', () => { + expect(screen.getByText('column_channel_name')).toBeInTheDocument(); + expect(screen.getByText('column_channel_type')).toBeInTheDocument(); + expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument(); + }); + + it('Should check if the data in the table is displayed properly', () => { + expect(screen.getByText('Dummy-Channel')).toBeInTheDocument(); + expect(screen.getAllByText('slack')[0]).toBeInTheDocument(); + expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx new file mode 100644 index 0000000000..0406df814f --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx @@ -0,0 +1,424 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable sonarjs/no-identical-functions */ + +import CreateAlertChannels from 'container/CreateAlertChannels'; +import { ChannelType } from 'container/CreateAlertChannels/config'; +import { + opsGenieDescriptionDefaultValue, + opsGenieMessageDefaultValue, + opsGeniePriorityDefaultValue, + pagerDutyAdditionalDetailsDefaultValue, + pagerDutyDescriptionDefaultVaule, + pagerDutySeverityTextDefaultValue, + slackDescriptionDefaultValue, + slackTitleDefaultValue, +} from 'mocks-server/__mockdata__/alerts'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { fireEvent, render, screen, waitFor } from 'tests/test-utils'; + +import { testLabelInputAndHelpValue } from './testUtils'; + +const successNotification = jest.fn(); +const errorNotification = jest.fn(); +jest.mock('hooks/useNotifications', () => ({ + __esModule: true, + useNotifications: jest.fn(() => ({ + notifications: { + success: successNotification, + error: errorNotification, + }, + })), +})); + +jest.mock('hooks/useFeatureFlag', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + active: true, + })), +})); + +describe('Create Alert Channel', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel ', () => { + beforeEach(() => { + render(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('Should check if the title is "New Notification Channels"', () => { + expect(screen.getByText('page_title_create')).toBeInTheDocument(); + }); + it('Should check if the name label and textbox are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_name', + testId: 'channel-name-textbox', + }); + }); + it('Should check if Send resolved alerts label and checkbox are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_send_resolved', + testId: 'field-send-resolved-checkbox', + }); + }); + it('Should check if channel type label and dropdown are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_type', + testId: 'channel-type-select', + }); + }); + // Default Channel type (Slack) fields + it('Should check if the selected item in the type dropdown has text "Slack"', () => { + expect(screen.getByText('Slack')).toBeInTheDocument(); + }); + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + }); + }); + it('Should check if Recepient label, input, and help text are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_recipient', + testId: 'slack-channel-textbox', + helpText: 'slack_channel_help', + }); + }); + + it('Should check if Title label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_title', + testId: 'title-textarea', + }); + }); + it('Should check if Title contains template', () => { + const titleTextArea = screen.getByTestId('title-textarea'); + + expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue); + }); + it('Should check if Description label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_description', + testId: 'description-textarea', + }); + }); + it('Should check if Description contains template', () => { + const descriptionTextArea = screen.getByTestId('description-textarea'); + + expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue); + }); + it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => { + expect(screen.getByText('button_save_channel')).toBeInTheDocument(); + expect(screen.getByText('button_test_channel')).toBeInTheDocument(); + expect(screen.getByText('button_return')).toBeInTheDocument(); + }); + it('Should check if saving the form without filling the name displays "Something went wrong"', async () => { + const saveButton = screen.getByRole('button', { + name: 'button_save_channel', + }); + + fireEvent.click(saveButton); + + await waitFor(() => + expect(errorNotification).toHaveBeenCalledWith({ + description: 'Something went wrong', + message: 'Error', + }), + ); + }); + it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => { + server.use( + rest.post('http://localhost/api/v1/testChannel', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + status: 'success', + data: 'test alert sent', + }), + ), + ), + ); + const testButton = screen.getByRole('button', { + name: 'button_test_channel', + }); + + fireEvent.click(testButton); + + await waitFor(() => + expect(successNotification).toHaveBeenCalledWith({ + message: 'Success', + description: 'channel_test_done', + }), + ); + }); + it('Should check if clicking on Test button shows "Something went wrong" error message if testing fails', async () => { + const testButton = screen.getByRole('button', { + name: 'button_test_channel', + }); + + fireEvent.click(testButton); + + await waitFor(() => + expect(errorNotification).toHaveBeenCalledWith({ + message: 'Error', + description: 'channel_test_failed', + }), + ); + }); + }); + describe('New Alert Channel Cascading Fields Based on Channel Type', () => { + describe('Webhook', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Webhook"', () => { + expect(screen.getByText('Webhook')).toBeInTheDocument(); + }); + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + }); + }); + it('Should check if Webhook User Name label, input, and help text are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_username', + testId: 'webhook-username-textbox', + helpText: 'help_webhook_username', + }); + }); + it('Should check if Password label and textbox, and help text are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'Password (optional)', + testId: 'webhook-password-textbox', + helpText: 'help_webhook_password', + }); + }); + }); + describe('PagerDuty', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => { + expect(screen.getByText('Pagerduty')).toBeInTheDocument(); + }); + it('Should check if Routing key label, required, and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_routing_key', + testId: 'pager-routing-key-textbox', + }); + }); + it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_description', + testId: 'pager-description-textarea', + helpText: 'help_pager_description', + }); + }); + it('Should check if the description contains default template', () => { + const descriptionTextArea = screen.getByTestId( + 'pager-description-textarea', + ); + + expect(descriptionTextArea).toHaveTextContent( + pagerDutyDescriptionDefaultVaule, + ); + }); + it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_severity', + testId: 'pager-severity-textbox', + helpText: 'help_pager_severity', + }); + }); + it('Should check if Severity contains the default template', () => { + const severityTextbox = screen.getByTestId('pager-severity-textbox'); + + expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue); + }); + it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_details', + testId: 'pager-additional-details-textarea', + helpText: 'help_pager_details', + }); + }); + it('Should check if Additional Information contains the default template', () => { + const detailsTextArea = screen.getByTestId( + 'pager-additional-details-textarea', + ); + + expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue); + }); + it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_group', + testId: 'pager-group-textarea', + helpText: 'help_pager_group', + }); + }); + it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_class', + testId: 'pager-class-textarea', + helpText: 'help_pager_class', + }); + }); + it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_client', + testId: 'pager-client-textarea', + helpText: 'help_pager_client', + }); + }); + it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => { + const clientTextArea = screen.getByTestId('pager-client-textarea'); + + expect(clientTextArea).toHaveValue('SigNoz Alert Manager'); + }); + it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_client_url', + testId: 'pager-client-url-textarea', + helpText: 'help_pager_client_url', + }); + }); + it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => { + const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea'); + + expect(clientUrlTextArea).toHaveValue( + 'https://enter-signoz-host-n-port-here/alerts', + ); + }); + }); + describe('Opsgenie', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => { + expect(screen.getByText('Opsgenie')).toBeInTheDocument(); + }); + + it('Should check if API key label, required, and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_api_key', + testId: 'opsgenie-api-key-textbox', + required: true, + }); + }); + + it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_message', + testId: 'opsgenie-message-textarea', + helpText: 'help_opsgenie_message', + required: true, + }); + }); + + it('Should check if Message contains the default template ', () => { + const messageTextArea = screen.getByTestId('opsgenie-message-textarea'); + + expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue); + }); + + it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_description', + testId: 'opsgenie-description-textarea', + helpText: 'help_opsgenie_description', + required: true, + }); + }); + + it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => { + const descriptionTextArea = screen.getByTestId( + 'opsgenie-description-textarea', + ); + + expect(descriptionTextArea).toHaveTextContent( + opsGenieDescriptionDefaultValue, + ); + }); + + it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_priority', + testId: 'opsgenie-priority-textarea', + helpText: 'help_opsgenie_priority', + required: true, + }); + }); + + it('Should check if Message contains the default template', () => { + const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea'); + + expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue); + }); + }); + describe('Opsgenie', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Email"', () => { + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_email_to', + testId: 'email-to-textbox', + helpText: 'help_email_to', + required: true, + }); + }); + }); + describe('Microsoft Teams', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "msteams"', () => { + expect(screen.getByText('msteams')).toBeInTheDocument(); + }); + + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + }); + }); + + it('Should check if Title label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_title', + testId: 'title-textarea', + }); + }); + + it('Should check if Title contains template', () => { + const titleTextArea = screen.getByTestId('title-textarea'); + + expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue); + }); + it('Should check if Description label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_description', + testId: 'description-textarea', + }); + }); + + it('Should check if Description contains template', () => { + const descriptionTextArea = screen.getByTestId('description-textarea'); + + expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue); + }); + }); + }); +}); diff --git a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannelNormalUser.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannelNormalUser.test.tsx new file mode 100644 index 0000000000..7c9ec5618f --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannelNormalUser.test.tsx @@ -0,0 +1,348 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable sonarjs/no-identical-functions */ + +import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app'; +import CreateAlertChannels from 'container/CreateAlertChannels'; +import { ChannelType } from 'container/CreateAlertChannels/config'; +import { + opsGenieDescriptionDefaultValue, + opsGenieMessageDefaultValue, + opsGeniePriorityDefaultValue, + pagerDutyAdditionalDetailsDefaultValue, + pagerDutyDescriptionDefaultVaule, + pagerDutySeverityTextDefaultValue, + slackDescriptionDefaultValue, + slackTitleDefaultValue, +} from 'mocks-server/__mockdata__/alerts'; +import { render, screen } from 'tests/test-utils'; + +import { testLabelInputAndHelpValue } from './testUtils'; + +describe('Create Alert Channel (Normal User)', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel ', () => { + beforeEach(() => { + render(); + }); + it('Should check if the title is "New Notification Channels"', () => { + expect(screen.getByText('page_title_create')).toBeInTheDocument(); + }); + it('Should check if the name label and textbox are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_name', + testId: 'channel-name-textbox', + }); + }); + it('Should check if Send resolved alerts label and checkbox are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_send_resolved', + testId: 'field-send-resolved-checkbox', + }); + }); + it('Should check if channel type label and dropdown are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_type', + testId: 'channel-type-select', + }); + }); + // Default Channel type (Slack) fields + it('Should check if the selected item in the type dropdown has text "Slack"', () => { + expect(screen.getByText('Slack')).toBeInTheDocument(); + }); + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + }); + }); + it('Should check if Recepient label, input, and help text are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_recipient', + testId: 'slack-channel-textbox', + helpText: 'slack_channel_help', + }); + }); + + it('Should check if Title label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_title', + testId: 'title-textarea', + }); + }); + it('Should check if Title contains template', () => { + const titleTextArea = screen.getByTestId('title-textarea'); + + expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue); + }); + it('Should check if Description label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_description', + testId: 'description-textarea', + }); + }); + it('Should check if Description contains template', () => { + const descriptionTextArea = screen.getByTestId('description-textarea'); + + expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue); + }); + it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => { + expect(screen.getByText('button_save_channel')).toBeInTheDocument(); + expect(screen.getByText('button_test_channel')).toBeInTheDocument(); + expect(screen.getByText('button_return')).toBeInTheDocument(); + }); + }); + describe('New Alert Channel Cascading Fields Based on Channel Type', () => { + describe('Webhook', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Webhook"', () => { + expect(screen.getByText('Webhook')).toBeInTheDocument(); + }); + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + }); + }); + it('Should check if Webhook User Name label, input, and help text are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_username', + testId: 'webhook-username-textbox', + helpText: 'help_webhook_username', + }); + }); + it('Should check if Password label and textbox, and help text are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'Password (optional)', + testId: 'webhook-password-textbox', + helpText: 'help_webhook_password', + }); + }); + }); + describe('PagerDuty', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => { + expect(screen.getByText('Pagerduty')).toBeInTheDocument(); + }); + it('Should check if Routing key label, required, and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_routing_key', + testId: 'pager-routing-key-textbox', + }); + }); + it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_description', + testId: 'pager-description-textarea', + helpText: 'help_pager_description', + }); + }); + it('Should check if the description contains default template', () => { + const descriptionTextArea = screen.getByTestId( + 'pager-description-textarea', + ); + + expect(descriptionTextArea).toHaveTextContent( + pagerDutyDescriptionDefaultVaule, + ); + }); + it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_severity', + testId: 'pager-severity-textbox', + helpText: 'help_pager_severity', + }); + }); + it('Should check if Severity contains the default template', () => { + const severityTextbox = screen.getByTestId('pager-severity-textbox'); + + expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue); + }); + it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_details', + testId: 'pager-additional-details-textarea', + helpText: 'help_pager_details', + }); + }); + it('Should check if Additional Information contains the default template', () => { + const detailsTextArea = screen.getByTestId( + 'pager-additional-details-textarea', + ); + + expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue); + }); + it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_group', + testId: 'pager-group-textarea', + helpText: 'help_pager_group', + }); + }); + it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_class', + testId: 'pager-class-textarea', + helpText: 'help_pager_class', + }); + }); + it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_client', + testId: 'pager-client-textarea', + helpText: 'help_pager_client', + }); + }); + it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => { + const clientTextArea = screen.getByTestId('pager-client-textarea'); + + expect(clientTextArea).toHaveValue('SigNoz Alert Manager'); + }); + it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_pager_client_url', + testId: 'pager-client-url-textarea', + helpText: 'help_pager_client_url', + }); + }); + it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => { + const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea'); + + expect(clientUrlTextArea).toHaveValue( + 'https://enter-signoz-host-n-port-here/alerts', + ); + }); + }); + describe('Opsgenie', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => { + expect(screen.getByText('Opsgenie')).toBeInTheDocument(); + }); + + it('Should check if API key label, required, and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_api_key', + testId: 'opsgenie-api-key-textbox', + required: true, + }); + }); + + it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_message', + testId: 'opsgenie-message-textarea', + helpText: 'help_opsgenie_message', + required: true, + }); + }); + + it('Should check if Message contains the default template ', () => { + const messageTextArea = screen.getByTestId('opsgenie-message-textarea'); + + expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue); + }); + + it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_description', + testId: 'opsgenie-description-textarea', + helpText: 'help_opsgenie_description', + required: true, + }); + }); + + it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => { + const descriptionTextArea = screen.getByTestId( + 'opsgenie-description-textarea', + ); + + expect(descriptionTextArea).toHaveTextContent( + opsGenieDescriptionDefaultValue, + ); + }); + + it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_opsgenie_priority', + testId: 'opsgenie-priority-textarea', + helpText: 'help_opsgenie_priority', + required: true, + }); + }); + + it('Should check if Message contains the default template', () => { + const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea'); + + expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue); + }); + }); + describe('Opsgenie', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Email"', () => { + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_email_to', + testId: 'email-to-textbox', + helpText: 'help_email_to', + required: true, + }); + }); + }); + describe('Microsoft Teams', () => { + beforeEach(() => { + render(); + }); + + it('Should check if the selected item in the type dropdown has text "Microsoft Teams (Supported in Paid Plans Only)"', () => { + expect( + screen.getByText('Microsoft Teams (Supported in Paid Plans Only)'), + ).toBeInTheDocument(); + }); + + it('Should check if the upgrade plan message is shown', () => { + expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument(); + expect( + screen.getByText(/This feature is available for paid plans only./), + ).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'Click here' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL); + expect(screen.getByText(/to Upgrade/)).toBeInTheDocument(); + }); + it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => { + expect( + screen.getByRole('button', { name: 'button_save_channel' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'button_test_channel' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'button_return' }), + ).toBeInTheDocument(); + }); + it('Should check if save and test buttons are disabled', () => { + expect( + screen.getByRole('button', { name: 'button_save_channel' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'button_test_channel' }), + ).toBeDisabled(); + }); + }); + }); +}); diff --git a/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannel.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannel.test.tsx new file mode 100644 index 0000000000..afd1a20bfd --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/EditAlertChannel.test.tsx @@ -0,0 +1,118 @@ +import EditAlertChannels from 'container/EditAlertChannels'; +import { + editAlertChannelInitialValue, + editSlackDescriptionDefaultValue, + slackTitleDefaultValue, +} from 'mocks-server/__mockdata__/alerts'; +import { render, screen } from 'tests/test-utils'; + +import { testLabelInputAndHelpValue } from './testUtils'; + +const successNotification = jest.fn(); +const errorNotification = jest.fn(); +jest.mock('hooks/useNotifications', () => ({ + __esModule: true, + useNotifications: jest.fn(() => ({ + notifications: { + success: successNotification, + error: errorNotification, + }, + })), +})); + +jest.mock('hooks/useFeatureFlag', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + active: true, + })), +})); + +describe('Should check if the edit alert channel is properly displayed ', () => { + beforeEach(() => { + render(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('Should check if the title is "Edit Notification Channels"', () => { + expect(screen.getByText('page_title_edit')).toBeInTheDocument(); + }); + + it('Should check if the name label and textbox are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_name', + testId: 'channel-name-textbox', + value: 'Dummy-Channel', + }); + }); + it('Should check if Send resolved alerts label and checkbox are displayed properly and the checkbox is checked ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_send_resolved', + testId: 'field-send-resolved-checkbox', + }); + expect(screen.getByTestId('field-send-resolved-checkbox')).toBeChecked(); + }); + + it('Should check if channel type label and dropdown are displayed properly', () => { + testLabelInputAndHelpValue({ + labelText: 'field_channel_type', + testId: 'channel-type-select', + }); + }); + + it('Should check if the selected item in the type dropdown has text "Slack"', () => { + expect(screen.getByText('Slack')).toBeInTheDocument(); + }); + + it('Should check if Webhook URL label and input are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_webhook_url', + testId: 'webhook-url-textbox', + value: + 'https://discord.com/api/webhooks/dummy_webhook_id/dummy_webhook_token/slack', + }); + }); + + it('Should check if Recepient label, input, and help text are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_recipient', + testId: 'slack-channel-textbox', + helpText: 'slack_channel_help', + value: '#dummy_channel', + }); + }); + + it('Should check if Title label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_title', + testId: 'title-textarea', + }); + }); + + it('Should check if Title contains template', () => { + const titleTextArea = screen.getByTestId('title-textarea'); + + expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue); + }); + + it('Should check if Description label and text area are displayed properly ', () => { + testLabelInputAndHelpValue({ + labelText: 'field_slack_description', + testId: 'description-textarea', + }); + }); + + it('Should check if Description contains template', () => { + const descriptionTextArea = screen.getByTestId('description-textarea'); + + expect(descriptionTextArea).toHaveTextContent( + editSlackDescriptionDefaultValue, + ); + }); + + it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => { + expect(screen.getByText('button_save_channel')).toBeInTheDocument(); + expect(screen.getByText('button_test_channel')).toBeInTheDocument(); + expect(screen.getByText('button_return')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/AllAlertChannels/__tests__/testUtils.ts b/frontend/src/container/AllAlertChannels/__tests__/testUtils.ts new file mode 100644 index 0000000000..bae773f2fb --- /dev/null +++ b/frontend/src/container/AllAlertChannels/__tests__/testUtils.ts @@ -0,0 +1,31 @@ +import { screen } from 'tests/test-utils'; + +export const testLabelInputAndHelpValue = ({ + labelText, + testId, + helpText, + required = false, + value, +}: { + labelText: string; + testId: string; + helpText?: string; + required?: boolean; + value?: string; +}): void => { + const label = screen.getByText(labelText); + expect(label).toBeInTheDocument(); + + const input = screen.getByTestId(testId); + expect(input).toBeInTheDocument(); + + if (helpText !== undefined) { + expect(screen.getByText(helpText)).toBeInTheDocument(); + } + if (required) { + expect(input).toBeRequired(); + } + if (value) { + expect(input).toHaveValue(value); + } +}; diff --git a/frontend/src/container/AllAlertChannels/index.tsx b/frontend/src/container/AllAlertChannels/index.tsx index 5f34264a60..85b42de094 100644 --- a/frontend/src/container/AllAlertChannels/index.tsx +++ b/frontend/src/container/AllAlertChannels/index.tsx @@ -1,13 +1,15 @@ import { PlusOutlined } from '@ant-design/icons'; import { Tooltip, Typography } from 'antd'; import getAll from 'api/channels/getAll'; +import logEvent from 'api/common/logEvent'; import Spinner from 'components/Spinner'; import TextToolTip from 'components/TextToolTip'; import ROUTES from 'constants/routes'; import useComponentPermission from 'hooks/useComponentPermission'; import useFetch from 'hooks/useFetch'; import history from 'lib/history'; -import { useCallback } from 'react'; +import { isUndefined } from 'lodash-es'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -31,6 +33,14 @@ function AlertChannels(): JSX.Element { const { loading, payload, error, errorMessage } = useFetch(getAll); + useEffect(() => { + if (!isUndefined(payload)) { + logEvent('Alert Channel: Channel list page visited', { + number: payload?.length, + }); + } + }, [payload]); + if (error) { return {errorMessage}; } diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx index e8c13d88cd..0dd46c0a64 100644 --- a/frontend/src/container/AllError/index.tsx +++ b/frontend/src/container/AllError/index.tsx @@ -12,6 +12,7 @@ import { ColumnType, TablePaginationConfig } from 'antd/es/table'; import { FilterValue, SorterResult } from 'antd/es/table/interface'; import { ColumnsType } from 'antd/lib/table'; import { FilterConfirmProps } from 'antd/lib/table/interface'; +import logEvent from 'api/common/logEvent'; import getAll from 'api/errors/getAll'; import getErrorCounts from 'api/errors/getErrorCounts'; import { ResizeTable } from 'components/ResizeTable'; @@ -23,7 +24,8 @@ import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute import useUrlQuery from 'hooks/useUrlQuery'; import createQueryParams from 'lib/createQueryParams'; import history from 'lib/history'; -import { useCallback, useEffect, useMemo } from 'react'; +import { isUndefined } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries } from 'react-query'; import { useSelector } from 'react-redux'; @@ -410,6 +412,26 @@ function AllErrors(): JSX.Element { [pathname], ); + const logEventCalledRef = useRef(false); + useEffect(() => { + if ( + !logEventCalledRef.current && + !isUndefined(errorCountResponse.data?.payload) + ) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + logEvent('Exception: List page visited', { + numberOfExceptions: errorCountResponse?.data?.payload, + selectedEnvironments, + resourceAttributeUsed: !!queries?.length, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errorCountResponse.data?.payload]); + return ( ( (state) => state.app, @@ -59,18 +63,31 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true', ); + const { notifications } = useNotifications(); + const isDarkMode = useIsDarkMode(); const { data: licenseData, isFetching } = useLicense(); + const isPremiumChatSupportEnabled = + useFeatureFlags(FeatureKeys.PREMIUM_SUPPORT)?.active || false; + + const isChatSupportEnabled = + useFeatureFlags(FeatureKeys.CHAT_SUPPORT)?.active || false; + + const isCloudUserVal = isCloudUser(); + + const showAddCreditCardModal = + isLoggedIn && + isChatSupportEnabled && + isCloudUserVal && + !isPremiumChatSupportEnabled && + !licenseData?.payload?.trialConvertedToSubscription; + const { pathname } = useLocation(); const { t } = useTranslation(['titles']); - const [ - getUserVersionResponse, - getUserLatestVersionResponse, - getDynamicConfigsResponse, - ] = useQueries([ + const [getUserVersionResponse, getUserLatestVersionResponse] = useQueries([ { queryFn: getUserVersion, queryKey: ['getUserVersion', user?.accessJwt], @@ -81,10 +98,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { queryKey: ['getUserLatestVersion', user?.accessJwt], enabled: isLoggedIn, }, - { - queryFn: getDynamicConfigs, - queryKey: ['getDynamicConfigs', user?.accessJwt], - }, ]); useEffect(() => { @@ -95,15 +108,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { if (getUserVersionResponse.status === 'idle' && isLoggedIn) { getUserVersionResponse.refetch(); } - if (getDynamicConfigsResponse.status === 'idle') { - getDynamicConfigsResponse.refetch(); - } - }, [ - getUserLatestVersionResponse, - getUserVersionResponse, - isLoggedIn, - getDynamicConfigsResponse, - ]); + }, [getUserLatestVersionResponse, getUserVersionResponse, isLoggedIn]); const { children } = props; @@ -111,9 +116,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const latestCurrentCounter = useRef(0); const latestVersionCounter = useRef(0); - const latestConfigCounter = useRef(0); - - const { notifications } = useNotifications(); const onCollapse = useCallback(() => { setCollapsed((collapsed) => !collapsed); @@ -189,23 +191,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { }, }); } - - if ( - getDynamicConfigsResponse.isFetched && - getDynamicConfigsResponse.isSuccess && - getDynamicConfigsResponse.data && - getDynamicConfigsResponse.data.payload && - latestConfigCounter.current === 0 - ) { - latestConfigCounter.current = 1; - - dispatch({ - type: UPDATE_CONFIGS, - payload: { - configs: getDynamicConfigsResponse.data.payload, - }, - }); - } }, [ dispatch, isLoggedIn, @@ -220,9 +205,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getUserLatestVersionResponse.isFetched, getUserVersionResponse.isFetched, getUserLatestVersionResponse.isSuccess, - getDynamicConfigsResponse.data, - getDynamicConfigsResponse.isFetched, - getDynamicConfigsResponse.isSuccess, notifications, ]); @@ -232,11 +214,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const pageTitle = t(routeKey); const renderFullScreen = pathname === ROUTES.GET_STARTED || - pathname === ROUTES.WORKSPACE_LOCKED || pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING || pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING || pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT || - pathname === ROUTES.GET_STARTED_AWS_MONITORING; + pathname === ROUTES.GET_STARTED_AWS_MONITORING || + pathname === ROUTES.GET_STARTED_AZURE_MONITORING; const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false); @@ -267,7 +249,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isTracesView = (): boolean => routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS'; + const isMessagingQueues = (): boolean => + routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL'; + const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD'; + const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY'; + const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW'; const isDashboardView = (): boolean => { /** * need to match using regex here as the getRoute function will not work for @@ -294,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isSideNavCollapsed = getLocalStorageKey(IS_SIDEBAR_COLLAPSED); + /** + * Note: Right now we don't have a page-level method to pass the sidebar collapse state. + * Since the use case for overriding is not widely needed, we are setting it here + * so that the workspace locked page will have an expanded sidebar regardless of how users + * have set it or what is stored in localStorage. This will not affect the localStorage config. + */ + const isWorkspaceLocked = pathname === ROUTES.WORKSPACE_LOCKED; + return ( )} -
- - - - {isToDisplayLayout && !renderFullScreen && } - {children} - +
+ }> + + + + {isToDisplayLayout && !renderFullScreen && } + {children} + + - +
+ + {showAddCreditCardModal && } ); } diff --git a/frontend/src/container/AppLayout/styles.ts b/frontend/src/container/AppLayout/styles.ts index 5edddcac40..c66d2ee4d8 100644 --- a/frontend/src/container/AppLayout/styles.ts +++ b/frontend/src/container/AppLayout/styles.ts @@ -13,7 +13,6 @@ export const Layout = styled(LayoutComponent)` `; export const LayoutContent = styled(LayoutComponent.Content)` - overflow-y: auto; height: 100%; &::-webkit-scrollbar { width: 0.1rem; diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index 248819723c..e366f068b2 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -19,10 +19,10 @@ import { ColumnsType } from 'antd/es/table'; import updateCreditCardApi from 'api/billing/checkout'; import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage'; import manageCreditCardApi from 'api/billing/manage'; +import logEvent from 'api/common/logEvent'; import Spinner from 'components/Spinner'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import useAxiosError from 'hooks/useAxiosError'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; @@ -137,8 +137,6 @@ export default function BillingContainer(): JSX.Element { Partial >({}); - const { trackEvent } = useAnalytics(); - const { isFetching, data: licensesData, error: licenseError } = useLicense(); const { user, org } = useSelector((state) => state.app); @@ -316,7 +314,7 @@ export default function BillingContainer(): JSX.Element { const handleBilling = useCallback(async () => { if (isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription) { - trackEvent('Billing : Upgrade Plan', { + logEvent('Billing : Upgrade Plan', { user: pick(user, ['email', 'userId', 'name']), org, }); @@ -327,7 +325,7 @@ export default function BillingContainer(): JSX.Element { cancelURL: window.location.href, }); } else { - trackEvent('Billing : Manage Billing', { + logEvent('Billing : Manage Billing', { user: pick(user, ['email', 'userId', 'name']), org, }); diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index d10b6fb225..7345fa4ef9 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -11,11 +11,12 @@ import testOpsGenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; import testSlackApi from 'api/channels/testSlack'; import testWebhookApi from 'api/channels/testWebhook'; +import logEvent from 'api/common/logEvent'; import ROUTES from 'constants/routes'; import FormAlertChannels from 'container/FormAlertChannels'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -43,6 +44,10 @@ function CreateAlertChannels({ const [formInstance] = Form.useForm(); + useEffect(() => { + logEvent('Alert Channel: Create channel page visited', {}); + }, []); + const [selectedConfig, setSelectedConfig] = useState< Partial< SlackChannel & @@ -139,19 +144,25 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } catch (error) { notifications.error({ message: 'Error', description: t('channel_creation_failed'), }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [prepareSlackRequest, t, notifications]); const prepareWebhookRequest = useCallback(() => { @@ -200,19 +211,25 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } catch (error) { notifications.error({ message: 'Error', description: t('channel_creation_failed'), }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [prepareWebhookRequest, t, notifications]); const preparePagerRequest = useCallback(() => { @@ -245,8 +262,8 @@ function CreateAlertChannels({ setSavingState(true); const request = preparePagerRequest(); - if (request) { - try { + try { + if (request) { const response = await createPagerApi(request); if (response.statusCode === 200) { @@ -255,20 +272,31 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } - } catch (e) { notifications.error({ message: 'Error', - description: t('channel_creation_failed'), + description: response.error || t('channel_creation_failed'), }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } + notifications.error({ + message: 'Error', + description: t('channel_creation_failed'), + }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } catch (error) { + notifications.error({ + message: 'Error', + description: t('channel_creation_failed'), + }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [t, notifications, preparePagerRequest]); const prepareOpsgenieRequest = useCallback( @@ -295,19 +323,25 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } catch (error) { notifications.error({ message: 'Error', description: t('channel_creation_failed'), }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [prepareOpsgenieRequest, t, notifications]); const prepareEmailRequest = useCallback( @@ -332,19 +366,25 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } catch (error) { notifications.error({ message: 'Error', description: t('channel_creation_failed'), }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [prepareEmailRequest, t, notifications]); const prepareMsTeamsRequest = useCallback( @@ -370,19 +410,25 @@ function CreateAlertChannels({ description: t('channel_creation_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_creation_failed'), - }); + return { status: 'success', statusMessage: t('channel_creation_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + return { + status: 'failed', + statusMessage: response.error || t('channel_creation_failed'), + }; } catch (error) { notifications.error({ message: 'Error', description: t('channel_creation_failed'), }); + return { status: 'failed', statusMessage: t('channel_creation_failed') }; + } finally { + setSavingState(false); } - setSavingState(false); }, [prepareMsTeamsRequest, t, notifications]); const onSaveHandler = useCallback( @@ -400,7 +446,15 @@ function CreateAlertChannels({ const functionToCall = functionMapper[value as keyof typeof functionMapper]; if (functionToCall) { - functionToCall(); + const result = await functionToCall(); + logEvent('Alert Channel: Save channel', { + type: value, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, + new: 'true', + status: result?.status, + statusMessage: result?.statusMessage, + }); } else { notifications.error({ message: 'Error', @@ -409,6 +463,7 @@ function CreateAlertChannels({ } } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ onSlackHandler, onWebhookHandler, @@ -472,14 +527,25 @@ function CreateAlertChannels({ description: t('channel_test_failed'), }); } + + logEvent('Alert Channel: Test notification', { + type: channelType, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, + new: 'true', + status: + response && response.statusCode === 200 ? 'Test success' : 'Test failed', + }); } catch (error) { notifications.error({ message: 'Error', description: t('channel_test_unexpected'), }); } + setTestingState(false); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ prepareWebhookRequest, t, diff --git a/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx b/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx index cd837b666b..52f4d52215 100644 --- a/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx +++ b/frontend/src/container/CreateAlertRule/SelectAlertType/index.tsx @@ -1,4 +1,6 @@ import { Row, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTypes } from 'types/api/alerts/alertTypes'; @@ -34,6 +36,13 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element { default: break; } + + logEvent('Alert: Sample alert link clicked', { + dataSource: ALERTS_DATA_SOURCE_MAP[option], + link: url, + page: 'New alert data source selection page', + }); + window.open(url, '_blank'); } const renderOptions = useMemo( diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index e5a4772f30..f7e491cd70 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -1,4 +1,5 @@ import { Form, Row } from 'antd'; +import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V4 } from 'constants/app'; import { QueryParams } from 'constants/query'; import FormAlertRules from 'container/FormAlertRules'; @@ -68,6 +69,8 @@ function CreateRules(): JSX.Element { useEffect(() => { if (alertType) { onSelectType(alertType); + } else { + logEvent('Alert: New alert data source selection page visited', {}); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [alertType]); diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index 3c2e956f14..0fc46beb33 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -11,6 +11,7 @@ import testOpsgenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; import testSlackApi from 'api/channels/testSlack'; import testWebhookApi from 'api/channels/testWebhook'; +import logEvent from 'api/common/logEvent'; import ROUTES from 'constants/routes'; import { ChannelType, @@ -89,7 +90,7 @@ function EditAlertChannels({ description: t('webhook_url_required'), }); setSavingState(false); - return; + return { status: 'failed', statusMessage: t('webhook_url_required') }; } const response = await editSlackApi(prepareSlackRequest()); @@ -101,13 +102,17 @@ function EditAlertChannels({ }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_edit_failed'), - }); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [prepareSlackRequest, t, notifications, selectedConfig]); const prepareWebhookRequest = useCallback(() => { @@ -136,13 +141,13 @@ function EditAlertChannels({ if (selectedConfig?.api_url === '') { showError(t('webhook_url_required')); setSavingState(false); - return; + return { status: 'failed', statusMessage: t('webhook_url_required') }; } if (username && (!password || password === '')) { showError(t('username_no_password')); setSavingState(false); - return; + return { status: 'failed', statusMessage: t('username_no_password') }; } const response = await editWebhookApi(prepareWebhookRequest()); @@ -154,10 +159,15 @@ function EditAlertChannels({ }); history.replace(ROUTES.ALL_CHANNELS); - } else { - showError(response.error || t('channel_edit_failed')); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + showError(response.error || t('channel_edit_failed')); + setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [prepareWebhookRequest, t, notifications, selectedConfig]); const prepareEmailRequest = useCallback( @@ -181,13 +191,18 @@ function EditAlertChannels({ description: t('channel_edit_done'), }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_edit_failed'), - }); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [prepareEmailRequest, t, notifications]); const preparePagerRequest = useCallback( @@ -218,7 +233,7 @@ function EditAlertChannels({ description: validationError, }); setSavingState(false); - return; + return { status: 'failed', statusMessage: validationError }; } const response = await editPagerApi(preparePagerRequest()); @@ -229,13 +244,18 @@ function EditAlertChannels({ }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_edit_failed'), - }); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [preparePagerRequest, notifications, selectedConfig, t]); const prepareOpsgenieRequest = useCallback( @@ -259,7 +279,7 @@ function EditAlertChannels({ description: t('api_key_required'), }); setSavingState(false); - return; + return { status: 'failed', statusMessage: t('api_key_required') }; } const response = await editOpsgenie(prepareOpsgenieRequest()); @@ -271,13 +291,18 @@ function EditAlertChannels({ }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_edit_failed'), - }); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [prepareOpsgenieRequest, t, notifications, selectedConfig]); const prepareMsTeamsRequest = useCallback( @@ -301,7 +326,7 @@ function EditAlertChannels({ description: t('webhook_url_required'), }); setSavingState(false); - return; + return { status: 'failed', statusMessage: t('webhook_url_required') }; } const response = await editMsTeamsApi(prepareMsTeamsRequest()); @@ -313,31 +338,46 @@ function EditAlertChannels({ }); history.replace(ROUTES.ALL_CHANNELS); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('channel_edit_failed'), - }); + return { status: 'success', statusMessage: t('channel_edit_done') }; } + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + setSavingState(false); + return { + status: 'failed', + statusMessage: response.error || t('channel_edit_failed'), + }; }, [prepareMsTeamsRequest, t, notifications, selectedConfig]); const onSaveHandler = useCallback( - (value: ChannelType) => { + async (value: ChannelType) => { + let result; if (value === ChannelType.Slack) { - onSlackEditHandler(); + result = await onSlackEditHandler(); } else if (value === ChannelType.Webhook) { - onWebhookEditHandler(); + result = await onWebhookEditHandler(); } else if (value === ChannelType.Pagerduty) { - onPagerEditHandler(); + result = await onPagerEditHandler(); } else if (value === ChannelType.MsTeams) { - onMsTeamsEditHandler(); + result = await onMsTeamsEditHandler(); } else if (value === ChannelType.Opsgenie) { - onOpsgenieEditHandler(); + result = await onOpsgenieEditHandler(); } else if (value === ChannelType.Email) { - onEmailEditHandler(); + result = await onEmailEditHandler(); } + logEvent('Alert Channel: Save channel', { + type: value, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, + new: 'false', + status: result?.status, + statusMessage: result?.statusMessage, + }); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ onSlackEditHandler, onWebhookEditHandler, @@ -399,6 +439,14 @@ function EditAlertChannels({ description: t('channel_test_failed'), }); } + logEvent('Alert Channel: Test notification', { + type: channelType, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, + new: 'false', + status: + response && response.statusCode === 200 ? 'Test success' : 'Test failed', + }); } catch (error) { notifications.error({ message: 'Error', @@ -407,6 +455,7 @@ function EditAlertChannels({ } setTestingState(false); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ t, prepareWebhookRequest, diff --git a/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.tsx b/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.tsx index af3d768d4b..1b13cb59c8 100644 --- a/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.tsx +++ b/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.tsx @@ -1,8 +1,34 @@ import './EmptyLogsSearch.styles.scss'; import { Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { useEffect, useRef } from 'react'; +import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder'; + +export default function EmptyLogsSearch({ + dataSource, + panelType, +}: { + dataSource: DataSource; + panelType: PanelTypeKeys; +}): JSX.Element { + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current) { + if (dataSource === DataSource.TRACES) { + logEvent('Traces Explorer: No results', { + panelType, + }); + } else if (dataSource === DataSource.LOGS) { + logEvent('Logs Explorer: No results', { + panelType, + }); + } + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); -export default function EmptyLogsSearch(): JSX.Element { return (
diff --git a/frontend/src/container/ErrorDetails/index.tsx b/frontend/src/container/ErrorDetails/index.tsx index 33a86f9f50..c6b0d5fa22 100644 --- a/frontend/src/container/ErrorDetails/index.tsx +++ b/frontend/src/container/ErrorDetails/index.tsx @@ -1,6 +1,7 @@ import './styles.scss'; import { Button, Divider, Space, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import getNextPrevId from 'api/errors/getNextPrevId'; import Editor from 'components/Editor'; import { ResizeTable } from 'components/ResizeTable'; @@ -9,8 +10,9 @@ import dayjs from 'dayjs'; import { useNotifications } from 'hooks/useNotifications'; import createQueryParams from 'lib/createQueryParams'; import history from 'lib/history'; +import { isUndefined } from 'lodash-es'; import { urlKey } from 'pages/ErrorDetails/utils'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; import { useLocation } from 'react-router-dom'; @@ -111,9 +113,29 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element { })); const onClickTraceHandler = (): void => { + logEvent('Exception: Navigate to trace detail page', { + groupId: errorDetail?.groupID, + spanId: errorDetail.spanID, + traceId: errorDetail.traceID, + exceptionId: errorDetail?.errorId, + }); history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`); }; + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(data)) { + logEvent('Exception: Detail page visited', { + groupId: errorDetail?.groupID, + spanId: errorDetail.spanID, + traceId: errorDetail.traceID, + exceptionId: errorDetail?.errorId, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + return ( <> {errorDetail.exceptionType} diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss index 2076b858f9..54e87fa458 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss @@ -91,8 +91,7 @@ box-shadow: none !important; &.ant-btn-round { - padding-inline-start: 10px; - padding-inline-end: 8px; + padding: 8px 12px 8px 10px; font-weight: 500; } diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index 1aaf22f796..5be22deb2e 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -14,10 +14,12 @@ import { Tooltip, Typography, } from 'antd'; +import logEvent from 'api/common/logEvent'; import axios from 'axios'; import cx from 'classnames'; import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils'; import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { LOCALSTORAGE } from 'constants/localStorage'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; @@ -32,6 +34,7 @@ import useErrorNotification from 'hooks/useErrorNotification'; import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; import { useNotifications } from 'hooks/useNotifications'; import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery'; +import { cloneDeep } from 'lodash-es'; import { Check, ConciergeBell, @@ -46,6 +49,7 @@ import { Dispatch, SetStateAction, useCallback, + useEffect, useMemo, useRef, useState, @@ -55,11 +59,13 @@ import { useHistory } from 'react-router-dom'; import { AppState } from 'store/reducers'; import { Dashboard } from 'types/api/dashboard/getAll'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; +import { DataSource, StringOperators } from 'types/common/queryBuilder'; import AppReducer from 'types/reducer/app'; import { USER_ROLES } from 'types/roles'; +import { PreservedViewsTypes } from './constants'; import ExplorerOptionsHideArea from './ExplorerOptionsHideArea'; +import { PreservedViewsInLocalStorage } from './types'; import { DATASOURCE_VS_ROUTES, generateRGBAFromHex, @@ -88,12 +94,34 @@ function ExplorerOptions({ const history = useHistory(); const ref = useRef(null); const isDarkMode = useIsDarkMode(); + const isLogsExplorer = sourcepage === DataSource.LOGS; + + const PRESERVED_VIEW_LOCAL_STORAGE_KEY = LOCALSTORAGE.LAST_USED_SAVED_VIEWS; + const PRESERVED_VIEW_TYPE = isLogsExplorer + ? PreservedViewsTypes.LOGS + : PreservedViewsTypes.TRACES; const onModalToggle = useCallback((value: boolean) => { setIsExport(value); }, []); + const { + currentQuery, + panelType, + isStagedQueryUpdated, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + const handleSaveViewModalToggle = (): void => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Save view clicked', { + panelType, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Save view clicked', { + panelType, + }); + } setIsSaveModalOpen(!isSaveModalOpen); }; @@ -103,19 +131,56 @@ function ExplorerOptions({ const { role } = useSelector((state) => state.app); + const handleConditionalQueryModification = useCallback((): string => { + if ( + query?.builder?.queryData?.[0]?.aggregateOperator !== StringOperators.NOOP + ) { + return JSON.stringify(query); + } + + // Modify aggregateOperator to count, as noop is not supported in alerts + const modifiedQuery = cloneDeep(query); + + modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT; + + return JSON.stringify(modifiedQuery); + }, [query]); + const onCreateAlertsHandler = useCallback(() => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Create alert', { + panelType, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Create alert', { + panelType, + }); + } + + const stringifiedQuery = handleConditionalQueryModification(); + history.push( `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent( - JSON.stringify(query), + stringifiedQuery, )}`, ); - }, [history, query]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleConditionalQueryModification, history]); const onCancel = (value: boolean) => (): void => { onModalToggle(value); }; const onAddToDashboard = (): void => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Add to dashboard clicked', { + panelType, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Add to dashboard clicked', { + panelType, + }); + } setIsExport(true); }; @@ -127,13 +192,6 @@ function ExplorerOptions({ refetch: refetchAllView, } = useGetAllViews(sourcepage); - const { - currentQuery, - panelType, - isStagedQueryUpdated, - redirectWithQueryBuilderData, - } = useQueryBuilder(); - const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType); const viewName = useGetSearchQueryParam(QueryParams.viewName) || ''; @@ -217,6 +275,31 @@ function ExplorerOptions({ [viewsData, handleExplorerTabChange], ); + const updatePreservedViewInLocalStorage = (option: { + key: string; + value: string; + }): void => { + // Retrieve stored views from local storage + const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY); + + // Initialize or parse the stored views + const updatedViews: PreservedViewsInLocalStorage = storedViews + ? JSON.parse(storedViews) + : {}; + + // Update the views with the new selection + updatedViews[PRESERVED_VIEW_TYPE] = { + key: option.key, + value: option.value, + }; + + // Save the updated views back to local storage + localStorage.setItem( + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + JSON.stringify(updatedViews), + ); + }; + const handleSelect = ( value: string, option: { key: string; value: string }, @@ -224,12 +307,47 @@ function ExplorerOptions({ onMenuItemSelectHandler({ key: option.key, }); + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Select view', { + panelType, + viewName: option?.value, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Select view', { + panelType, + viewName: option?.value, + }); + } + + updatePreservedViewInLocalStorage(option); + if (ref.current) { ref.current.blur(); } }; + const removeCurrentViewFromLocalStorage = (): void => { + // Retrieve stored views from local storage + const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY); + + if (storedViews) { + // Parse the stored views + const parsedViews = JSON.parse(storedViews); + + // Remove the current view type from the parsed views + delete parsedViews[PRESERVED_VIEW_TYPE]; + + // Update local storage with the modified views + localStorage.setItem( + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + JSON.stringify(parsedViews), + ); + } + }; + const handleClearSelect = (): void => { + removeCurrentViewFromLocalStorage(); + history.replace(DATASOURCE_VS_ROUTES[sourcepage]); }; @@ -259,6 +377,17 @@ function ExplorerOptions({ viewName: newViewName, setNewViewName, }); + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Save view successful', { + panelType, + viewName: newViewName, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Save view successful', { + panelType, + viewName: newViewName, + }); + } }; // TODO: Remove this and move this to scss file @@ -288,6 +417,44 @@ function ExplorerOptions({ const isEditDeleteSupported = allowedRoles.includes(role as string); + const [ + isRecentlyUsedSavedViewSelected, + setIsRecentlyUsedSavedViewSelected, + ] = useState(false); + + useEffect(() => { + const parsedPreservedView = JSON.parse( + localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY) || '{}', + ); + + const preservedView = parsedPreservedView[PRESERVED_VIEW_TYPE] || {}; + + let timeoutId: string | number | NodeJS.Timeout | undefined; + + if ( + !!preservedView?.key && + viewsData?.data?.data && + !(!!viewName || !!viewKey) && + !isRecentlyUsedSavedViewSelected + ) { + // prevent the race condition with useShareBuilderUrl + timeoutId = setTimeout(() => { + onMenuItemSelectHandler({ key: preservedView.key }); + }, 0); + setIsRecentlyUsedSavedViewSelected(false); + } + + return (): void => clearTimeout(timeoutId); + }, [ + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + PRESERVED_VIEW_TYPE, + isRecentlyUsedSavedViewSelected, + onMenuItemSelectHandler, + viewKey, + viewName, + viewsData?.data?.data, + ]); + return (
{isQueryUpdated && !isExplorerOptionHidden && ( @@ -406,12 +573,12 @@ function ExplorerOptions({ - {sourcepage === DataSource.LOGS + {isLogsExplorer ? 'Learn more about Logs explorer ' : 'Learn more about Traces explorer '} } + data-testid="hide-toolbar" />
@@ -460,6 +628,7 @@ function ExplorerOptions({ icon={} onClick={onSaveHandler} disabled={isSaveViewLoading} + data-testid="save-view-btn" > Save this view , @@ -499,7 +668,7 @@ function ExplorerOptions({ export interface ExplorerOptionsProps { isLoading?: boolean; - onExport: (dashboard: Dashboard | null) => void; + onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void; query: Query | null; disabled: boolean; sourcepage: DataSource; diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionsHideArea.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptionsHideArea.tsx index a420c25ecc..efdaef1cd1 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptionsHideArea.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionsHideArea.tsx @@ -65,6 +65,7 @@ function ExplorerOptionsHideArea({ // style={{ alignSelf: 'center', marginRight: 'calc(10% - 20px)' }} className="explorer-show-btn" onClick={handleShowExplorerOption} + data-testid="show-explorer-option" >
diff --git a/frontend/src/container/ExplorerOptions/constants.ts b/frontend/src/container/ExplorerOptions/constants.ts new file mode 100644 index 0000000000..4a4e63f612 --- /dev/null +++ b/frontend/src/container/ExplorerOptions/constants.ts @@ -0,0 +1,4 @@ +export enum PreservedViewsTypes { + LOGS = 'logs', + TRACES = 'traces', +} diff --git a/frontend/src/container/ExplorerOptions/types.ts b/frontend/src/container/ExplorerOptions/types.ts index 398fe0d8a0..5f71efd033 100644 --- a/frontend/src/container/ExplorerOptions/types.ts +++ b/frontend/src/container/ExplorerOptions/types.ts @@ -8,6 +8,8 @@ import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types'; import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder'; +import { PreservedViewsTypes } from './constants'; + export interface SaveNewViewHandlerProps { viewName: string; compositeQuery: ICompositeMetricQuery; @@ -26,3 +28,11 @@ export interface SaveNewViewHandlerProps { redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData']; setNewViewName: Dispatch>; } + +export type PreservedViewType = + | PreservedViewsTypes.LOGS + | PreservedViewsTypes.TRACES; + +export type PreservedViewsInLocalStorage = Partial< + Record +>; diff --git a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx index df2d4f8720..a0927e3692 100644 --- a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx +++ b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx @@ -1,5 +1,6 @@ import { Button, Typography } from 'antd'; import createDashboard from 'api/dashboard/create'; +import { ENTITY_VERSION_V4 } from 'constants/app'; import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard'; import useAxiosError from 'hooks/useAxiosError'; import { useCallback, useMemo, useState } from 'react'; @@ -40,7 +41,7 @@ function ExportPanelContainer({ } = useMutation(createDashboard, { onSuccess: (data) => { if (data.payload) { - onExport(data?.payload); + onExport(data?.payload, true); } refetch(); }, @@ -54,7 +55,7 @@ function ExportPanelContainer({ ({ uuid }) => uuid === selectedDashboardId, ); - onExport(currentSelectedDashboard || null); + onExport(currentSelectedDashboard || null, false); }, [data, selectedDashboardId, onExport]); const handleSelect = useCallback( @@ -70,6 +71,7 @@ function ExportPanelContainer({ ns: 'dashboard', }), uploadedGrafana: false, + version: ENTITY_VERSION_V4, }); }, [t, createNewDashboard]); diff --git a/frontend/src/container/ExportPanel/index.tsx b/frontend/src/container/ExportPanel/index.tsx index f302d83212..86d5f44139 100644 --- a/frontend/src/container/ExportPanel/index.tsx +++ b/frontend/src/container/ExportPanel/index.tsx @@ -40,7 +40,7 @@ function ExportPanel({ export interface ExportPanelProps { isLoading?: boolean; - onExport: (dashboard: Dashboard | null) => void; + onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void; query: Query | null; } diff --git a/frontend/src/container/FormAlertChannels/Settings/Email.tsx b/frontend/src/container/FormAlertChannels/Settings/Email.tsx index 398e172a57..4d57d72f6d 100644 --- a/frontend/src/container/FormAlertChannels/Settings/Email.tsx +++ b/frontend/src/container/FormAlertChannels/Settings/Email.tsx @@ -27,6 +27,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element { diff --git a/frontend/src/container/FormAlertChannels/Settings/MsTeams.tsx b/frontend/src/container/FormAlertChannels/Settings/MsTeams.tsx index 48751f4acc..52ef85acff 100644 --- a/frontend/src/container/FormAlertChannels/Settings/MsTeams.tsx +++ b/frontend/src/container/FormAlertChannels/Settings/MsTeams.tsx @@ -17,6 +17,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element { webhook_url: event.target.value, })); }} + data-testid="webhook-url-textbox" /> @@ -30,6 +31,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element { title: event.target.value, })) } + data-testid="title-textarea" /> @@ -41,6 +43,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element { text: event.target.value, })) } + data-testid="description-textarea" placeholder={t('placeholder_slack_description')} /> diff --git a/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx index 009dd01882..1472ca0b4e 100644 --- a/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx +++ b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx @@ -20,7 +20,10 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element { return ( <> - + @@ -46,6 +50,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element { rows={4} onChange={handleInputChange('description')} placeholder={t('placeholder_opsgenie_description')} + data-testid="opsgenie-description-textarea" /> @@ -59,6 +64,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element { rows={4} onChange={handleInputChange('priority')} placeholder={t('placeholder_opsgenie_priority')} + data-testid="opsgenie-priority-textarea" /> diff --git a/frontend/src/container/FormAlertChannels/Settings/Pager.tsx b/frontend/src/container/FormAlertChannels/Settings/Pager.tsx index ec228f4b8d..61df458941 100644 --- a/frontend/src/container/FormAlertChannels/Settings/Pager.tsx +++ b/frontend/src/container/FormAlertChannels/Settings/Pager.tsx @@ -18,6 +18,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { routing_key: event.target.value, })); }} + data-testid="pager-routing-key-textbox" /> @@ -36,6 +37,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { })) } placeholder={t('placeholder_pager_description')} + data-testid="pager-description-textarea" /> @@ -51,6 +53,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { severity: event.target.value, })) } + data-testid="pager-severity-textbox" /> @@ -67,6 +70,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { details: event.target.value, })) } + data-testid="pager-additional-details-textarea" /> @@ -97,6 +101,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { group: event.target.value, })) } + data-testid="pager-group-textarea" /> @@ -112,6 +117,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { class: event.target.value, })) } + data-testid="pager-class-textarea" /> @@ -141,6 +148,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element { client_url: event.target.value, })) } + data-testid="pager-client-url-textarea" /> diff --git a/frontend/src/container/FormAlertChannels/Settings/Slack.tsx b/frontend/src/container/FormAlertChannels/Settings/Slack.tsx index c344df8ff5..5321fa5fe6 100644 --- a/frontend/src/container/FormAlertChannels/Settings/Slack.tsx +++ b/frontend/src/container/FormAlertChannels/Settings/Slack.tsx @@ -19,6 +19,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element { api_url: event.target.value, })); }} + data-testid="webhook-url-textbox" /> @@ -34,11 +35,13 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element { channel: event.target.value, })) } + data-testid="slack-channel-textbox" />