Compare commits
69 Commits
v0.51.0-cl
...
v0.52.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703983a5f9 | ||
|
|
766a2123c5 | ||
|
|
1308f0f15f | ||
|
|
6c634b99d0 | ||
|
|
9856335840 | ||
|
|
e85b405396 | ||
|
|
e2e965bc7f | ||
|
|
7811fdd17a | ||
|
|
0dca1237b9 | ||
|
|
f3d73f6d44 | ||
|
|
187927403a | ||
|
|
0157b47424 | ||
|
|
156905afc7 | ||
|
|
a4878f6430 | ||
|
|
4489df6f39 | ||
|
|
06c075466b | ||
|
|
62be3e7c13 | ||
|
|
bb84960442 | ||
|
|
52199361d5 | ||
|
|
f031845300 | ||
|
|
6f73bb6eca | ||
|
|
fe398bcc49 | ||
|
|
6781c29082 | ||
|
|
eb146491f2 | ||
|
|
ae325ec1ca | ||
|
|
fd6f0574f5 | ||
|
|
b819a90c80 | ||
|
|
a6848f6abd | ||
|
|
abe65975c9 | ||
|
|
5cedd57aa2 | ||
|
|
80a7b9d16d | ||
|
|
9f7b2542ec | ||
|
|
4a4c9f26a2 | ||
|
|
c957c0f757 | ||
|
|
3ff0aa4b4b | ||
|
|
063c9adba6 | ||
|
|
5c3ce146fa | ||
|
|
481bb6e8b8 | ||
|
|
61e6316736 | ||
|
|
f9d1494657 | ||
|
|
0021b4d784 | ||
|
|
a5d5800871 | ||
|
|
16dc90bbd1 | ||
|
|
fff61379fe | ||
|
|
08a415032c | ||
|
|
3783ffdd4c | ||
|
|
a8e4359d95 | ||
|
|
d9e94a4067 | ||
|
|
ae19eaa76a | ||
|
|
fff9954da2 | ||
|
|
a476c68f7e | ||
|
|
fc15aa6f1c | ||
|
|
220edd139a | ||
|
|
4192fd573d | ||
|
|
ca13d80205 | ||
|
|
59121bd932 | ||
|
|
aef935a817 | ||
|
|
f300518d61 | ||
|
|
18b608a1d8 | ||
|
|
738d62c9cf | ||
|
|
38e694cd36 | ||
|
|
1281330c52 | ||
|
|
7b7cca7db7 | ||
|
|
3134e8c1cf | ||
|
|
d00024b64a | ||
|
|
4360cd0397 | ||
|
|
a688b6c60e | ||
|
|
8d84ce8f06 | ||
|
|
09ea7b9eb5 |
@@ -146,7 +146,7 @@ services:
|
||||
condition: on-failure
|
||||
|
||||
query-service:
|
||||
image: signoz/query-service:0.49.1
|
||||
image: signoz/query-service:0.52.0
|
||||
command:
|
||||
[
|
||||
"-config=/root/config/prometheus.yml",
|
||||
@@ -186,7 +186,7 @@ services:
|
||||
<<: *db-depend
|
||||
|
||||
frontend:
|
||||
image: signoz/frontend:0.48.0
|
||||
image: signoz/frontend:0.52.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.102.2
|
||||
image: signoz/signoz-otel-collector:0.102.4
|
||||
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.102.2
|
||||
image: signoz/signoz-schema-migrator:0.102.4
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -36,6 +36,7 @@ receivers:
|
||||
# endpoint: 0.0.0.0:6832
|
||||
hostmetrics:
|
||||
collection_interval: 30s
|
||||
root_path: /hostfs
|
||||
scrapers:
|
||||
cpu: {}
|
||||
load: {}
|
||||
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
- --storage.path=/data
|
||||
|
||||
otel-collector-migrator:
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.4}
|
||||
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.102.2
|
||||
image: signoz/signoz-otel-collector:0.102.4
|
||||
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:
|
||||
|
||||
@@ -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.49.1}
|
||||
image: signoz/query-service:${DOCKER_TAG:-0.52.0}
|
||||
container_name: signoz-query-service
|
||||
command:
|
||||
[
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
<<: *db-depend
|
||||
|
||||
frontend:
|
||||
image: signoz/frontend:${DOCKER_TAG:-0.49.1}
|
||||
image: signoz/frontend:${DOCKER_TAG:-0.52.0}
|
||||
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.102.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.4}
|
||||
container_name: otel-migrator
|
||||
command:
|
||||
- "--dsn=tcp://clickhouse:9000"
|
||||
@@ -230,7 +230,7 @@ services:
|
||||
|
||||
|
||||
otel-collector:
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.4}
|
||||
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
|
||||
|
||||
@@ -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.49.1}
|
||||
image: signoz/query-service:${DOCKER_TAG:-0.52.0}
|
||||
container_name: signoz-query-service
|
||||
command:
|
||||
[
|
||||
@@ -203,7 +203,7 @@ services:
|
||||
<<: *db-depend
|
||||
|
||||
frontend:
|
||||
image: signoz/frontend:${DOCKER_TAG:-0.49.1}
|
||||
image: signoz/frontend:${DOCKER_TAG:-0.52.0}
|
||||
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.102.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.4}
|
||||
container_name: otel-migrator
|
||||
command:
|
||||
- "--dsn=tcp://clickhouse:9000"
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
|
||||
|
||||
otel-collector:
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.4}
|
||||
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
|
||||
|
||||
@@ -36,6 +36,7 @@ receivers:
|
||||
# endpoint: 0.0.0.0:6832
|
||||
hostmetrics:
|
||||
collection_interval: 30s
|
||||
root_path: /hostfs
|
||||
scrapers:
|
||||
cpu: {}
|
||||
load: {}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
88
ee/query-service/app/api/featureFlags_test.go
Normal file
88
ee/query-service/app/api/featureFlags_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -27,6 +29,8 @@ import (
|
||||
"go.signoz.io/signoz/ee/query-service/integrations/gateway"
|
||||
"go.signoz.io/signoz/ee/query-service/interfaces"
|
||||
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"
|
||||
@@ -40,6 +44,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"
|
||||
@@ -109,6 +114,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 {
|
||||
@@ -172,6 +181,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 {
|
||||
@@ -302,7 +318,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)
|
||||
@@ -319,7 +335,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)
|
||||
|
||||
@@ -337,7 +363,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler) (*http.Server, e
|
||||
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)
|
||||
@@ -393,6 +419,15 @@ func (lrw *loggingResponseWriter) Flush() {
|
||||
lrw.ResponseWriter.(http.Flusher).Flush()
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -711,6 +746,7 @@ func makeRulesManager(
|
||||
DisableRules: disableRules,
|
||||
FeatureFlags: fm,
|
||||
Reader: ch,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
}
|
||||
|
||||
// create Manager
|
||||
|
||||
@@ -13,6 +13,8 @@ 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
isUserFetching: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoggedIn) {
|
||||
history.push(ROUTES.LOGIN);
|
||||
history.push(ROUTES.LOGIN, { from: pathname });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -66,6 +66,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: {
|
||||
@@ -82,7 +90,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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,6 +96,10 @@ const interceptorRejected = async (
|
||||
}
|
||||
};
|
||||
|
||||
const interceptorRejectedBase = async (
|
||||
value: AxiosResponse<any>,
|
||||
): Promise<AxiosResponse<any>> => 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}`,
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { isNil } from 'lodash-es';
|
||||
|
||||
const getTopLevelOperations = async (): Promise<ServiceDataProps> => {
|
||||
const response = await axios.post(`/service/top_level_operations`);
|
||||
interface GetTopLevelOperationsProps {
|
||||
service?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
const getTopLevelOperations = async (
|
||||
props: GetTopLevelOperationsProps,
|
||||
): Promise<ServiceDataProps> => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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 { 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<License | null>(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<CheckoutSuccessPayloadProps, unknown>,
|
||||
): 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: `intercom icon`,
|
||||
page: '',
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
licenseKey: activeLicense?.key || '',
|
||||
successURL: window.location.href,
|
||||
cancelURL: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-support-gateway">
|
||||
<Button
|
||||
className="chat-support-gateway-btn"
|
||||
onClick={(): void => {
|
||||
logEvent('Disabled Chat Support: Clicked', {
|
||||
source: `intercom icon`,
|
||||
page: '',
|
||||
});
|
||||
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 28 32"
|
||||
className="chat-support-gateway-btn-icon"
|
||||
>
|
||||
<path d="M28 32s-4.714-1.855-8.527-3.34H3.437C1.54 28.66 0 27.026 0 25.013V3.644C0 1.633 1.54 0 3.437 0h21.125c1.898 0 3.437 1.632 3.437 3.645v18.404H28V32zm-4.139-11.982a.88.88 0 00-1.292-.105c-.03.026-3.015 2.681-8.57 2.681-5.486 0-8.517-2.636-8.571-2.684a.88.88 0 00-1.29.107 1.01 1.01 0 00-.219.708.992.992 0 00.318.664c.142.128 3.537 3.15 9.762 3.15 6.226 0 9.621-3.022 9.763-3.15a.992.992 0 00.317-.664 1.01 1.01 0 00-.218-.707z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx
Normal file
184
frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
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 { 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<string, unknown>;
|
||||
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<License | null>(null);
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] = useState(
|
||||
false,
|
||||
);
|
||||
|
||||
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) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
} else {
|
||||
logEvent(eventName, attributes);
|
||||
if (window.Intercom && !intercomMessageDisabled) {
|
||||
window.Intercom('showNewMessage', defaultTo(message, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (
|
||||
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
|
||||
): 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: '',
|
||||
...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
|
||||
<div className="facing-issue-button">
|
||||
<Tooltip
|
||||
title={onHoverText}
|
||||
autoAdjustOverflow
|
||||
style={{ padding: 8 }}
|
||||
overlayClassName="tooltip-overlay"
|
||||
>
|
||||
<Button
|
||||
className={cx('periscope-btn', 'facing-issue-button', className)}
|
||||
onClick={handleFacingIssuesClick}
|
||||
icon={<HelpCircle size={14} />}
|
||||
>
|
||||
{buttonText || 'Facing issues?'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
LaunchChatSupport.defaultProps = {
|
||||
message: '',
|
||||
buttonText: '',
|
||||
className: '',
|
||||
onHoverText: '',
|
||||
intercomMessageDisabled: false,
|
||||
};
|
||||
|
||||
export default LaunchChatSupport;
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { VIEWS } from './constants';
|
||||
@@ -9,6 +10,7 @@ export type LogDetailProps = {
|
||||
log: ILog | null;
|
||||
selectedTab: VIEWS;
|
||||
isListViewPanel?: boolean;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
@@ -6,10 +6,13 @@ 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 { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import {
|
||||
@@ -21,9 +24,10 @@ 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 { VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailProps } from './LogDetail.interfaces';
|
||||
@@ -36,6 +40,7 @@ function LogDetail({
|
||||
onClickActionItem,
|
||||
selectedTab,
|
||||
isListViewPanel = false,
|
||||
listViewPanelSelectedFields,
|
||||
}: LogDetailProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
@@ -45,6 +50,19 @@ function LogDetail({
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>();
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(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();
|
||||
|
||||
@@ -192,6 +210,8 @@ function LogDetail({
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
|
||||
|
||||
@@ -28,12 +28,17 @@ 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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
return logData.attributes_string?.log_level || LogType.INFO;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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, unknown>,
|
||||
): 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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
|
||||
import { ColumnGroupType, ColumnType } from 'antd/es/table';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -96,7 +96,7 @@ function DynamicColumnTable({
|
||||
return (
|
||||
<div className="DynamicColumnTable">
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
{facingIssueBtn && <FacingIssueBtn {...facingIssueBtn} />}
|
||||
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
|
||||
{dynamicColumns && (
|
||||
<Dropdown
|
||||
getPopupContainer={popupContainer}
|
||||
|
||||
@@ -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,7 @@ export interface DynamicColumnTableProps extends TableProps<any> {
|
||||
tablesource: typeof TableDataSource[keyof typeof TableDataSource];
|
||||
dynamicColumns: TableProps<any>['columns'];
|
||||
onDragColumn?: (fromIndex: number, toIndex: number) => void;
|
||||
facingIssueBtn?: FacingIssueBtnProps;
|
||||
facingIssueBtn?: LaunchChatSupportProps;
|
||||
shouldSendAlertsLogEvent?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +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<string, unknown>;
|
||||
message?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
onHoverText?: string;
|
||||
intercomMessageDisabled?: boolean;
|
||||
}
|
||||
|
||||
function FacingIssueBtn({
|
||||
attributes,
|
||||
eventName,
|
||||
message = '',
|
||||
buttonText = '',
|
||||
className = '',
|
||||
onHoverText = '',
|
||||
intercomMessageDisabled = false,
|
||||
}: FacingIssueBtnProps): JSX.Element | null {
|
||||
const handleFacingIssuesClick = (): void => {
|
||||
logEvent(eventName, attributes);
|
||||
|
||||
if (window.Intercom && !intercomMessageDisabled) {
|
||||
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
|
||||
<div className="facing-issue-button">
|
||||
<Tooltip
|
||||
title={onHoverText}
|
||||
autoAdjustOverflow
|
||||
style={{ padding: 8 }}
|
||||
overlayClassName="tooltip-overlay"
|
||||
>
|
||||
<Button
|
||||
className={cx('periscope-btn', 'facing-issue-button', className)}
|
||||
onClick={handleFacingIssuesClick}
|
||||
icon={<HelpCircle size={14} />}
|
||||
>
|
||||
{buttonText || 'Facing issues?'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
FacingIssueBtn.defaultProps = {
|
||||
message: '',
|
||||
buttonText: '',
|
||||
className: '',
|
||||
onHoverText: '',
|
||||
intercomMessageDisabled: false,
|
||||
};
|
||||
|
||||
export default FacingIssueBtn;
|
||||
@@ -19,6 +19,6 @@ export enum FeatureKeys {
|
||||
OSS = 'OSS',
|
||||
ONBOARDING = 'ONBOARDING',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
PLANNED_MAINTENANCE = 'PLANNED_MAINTENANCE',
|
||||
GATEWAY = 'GATEWAY',
|
||||
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ export const metricQueryFunctionOptions: SelectOption<string, string>[] = [
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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(<AlertChannels />);
|
||||
});
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<AlertChannels />);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<CreateAlertChannels preType={ChannelType.Slack} />);
|
||||
});
|
||||
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(<CreateAlertChannels preType={ChannelType.Webhook} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Email} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.MsTeams} />);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<CreateAlertChannels preType={ChannelType.Slack} />);
|
||||
});
|
||||
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(<CreateAlertChannels preType={ChannelType.Webhook} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.Email} />);
|
||||
});
|
||||
|
||||
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(<CreateAlertChannels preType={ChannelType.MsTeams} />);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -24,6 +24,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
.chat-support-gateway-btn {
|
||||
max-width: 48px;
|
||||
width: 48px;
|
||||
max-height: 48px;
|
||||
height: 48px;
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-color: #f25733;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
color: white !important;
|
||||
border-color: white !important;
|
||||
}
|
||||
|
||||
.chat-support-gateway-btn-icon {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-credit-card-btn,
|
||||
.cancel-btn {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.highlight-text {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
padding-right: 4px;
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.add-credit-card-modal {
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
background: var(--bg-slate-500, #161922);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.add-credit-card-btn {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.isDarkMode {
|
||||
.app-layout {
|
||||
.app-content {
|
||||
|
||||
@@ -9,12 +9,15 @@ import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import getUserLatestVersion from 'api/user/getLatestVersion';
|
||||
import getUserVersion from 'api/user/getVersion';
|
||||
import cx from 'classnames';
|
||||
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { IS_SIDEBAR_COLLAPSED } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useFeatureFlags from 'hooks/useFeatureFlag';
|
||||
import useLicense from 'hooks/useLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
@@ -49,6 +52,7 @@ import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||
import { ChildrenContainer, Layout, LayoutContent } from './styles';
|
||||
import { getRouteKey } from './utils';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
const { isLoggedIn, user, role } = useSelector<AppState, AppReducer>(
|
||||
(state) => state.app,
|
||||
@@ -58,10 +62,19 @@ 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 showAddCreditCardModal =
|
||||
!isPremiumChatSupportEnabled &&
|
||||
!licenseData?.payload?.trialConvertedToSubscription;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation(['titles']);
|
||||
|
||||
@@ -95,8 +108,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
const latestCurrentCounter = useRef(0);
|
||||
const latestVersionCounter = useRef(0);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onCollapse = useCallback(() => {
|
||||
setCollapsed((collapsed) => !collapsed);
|
||||
}, []);
|
||||
@@ -331,6 +342,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</Sentry.ErrorBoundary>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
|
||||
<Input
|
||||
onChange={handleInputChange('to')}
|
||||
placeholder={t('placeholder_email_to')}
|
||||
data-testid="email-to-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
webhook_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -30,6 +31,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="title-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -41,6 +43,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
|
||||
text: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="description-textarea"
|
||||
placeholder={t('placeholder_slack_description')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -20,7 +20,10 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="api_key" label={t('field_opsgenie_api_key')} required>
|
||||
<Input onChange={handleInputChange('api_key')} />
|
||||
<Input
|
||||
onChange={handleInputChange('api_key')}
|
||||
data-testid="opsgenie-api-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -33,6 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
|
||||
rows={4}
|
||||
onChange={handleInputChange('message')}
|
||||
placeholder={t('placeholder_opsgenie_message')}
|
||||
data-testid="opsgenie-message-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
routing_key: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="pager-routing-key-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -36,6 +37,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
}))
|
||||
}
|
||||
placeholder={t('placeholder_pager_description')}
|
||||
data-testid="pager-description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -51,6 +53,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
severity: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-severity-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -67,6 +70,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
details: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-additional-details-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -97,6 +101,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
group: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-group-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -112,6 +117,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
class: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-class-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -126,6 +132,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
client: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-client-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -141,6 +148,7 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
|
||||
client_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="pager-client-url-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
api_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -34,11 +35,13 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
channel: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="slack-channel-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="title" label={t('field_slack_title')}>
|
||||
<TextArea
|
||||
data-testid="title-textarea"
|
||||
rows={4}
|
||||
// value={`[{{ .Status | toUpper }}{{ if eq .Status \"firing\" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n{{\" \"}}(\n{{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}=\"{{ $label.Value -}}\"\n {{- end }}\n{{- end -}}\n)\n{{- end }}`}
|
||||
onChange={(event): void =>
|
||||
@@ -59,6 +62,7 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
|
||||
}))
|
||||
}
|
||||
placeholder={t('placeholder_slack_description')}
|
||||
data-testid="description-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -17,6 +17,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
api_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -31,6 +32,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
username: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-username-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -46,6 +48,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
|
||||
password: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -85,6 +85,7 @@ function FormAlertChannels({
|
||||
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
|
||||
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
|
||||
<Input
|
||||
data-testid="channel-name-textbox"
|
||||
disabled={editing}
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((state) => ({
|
||||
@@ -102,6 +103,7 @@ function FormAlertChannels({
|
||||
>
|
||||
<Switch
|
||||
defaultChecked={initialValue?.send_resolved}
|
||||
data-testid="field-send-resolved-checkbox"
|
||||
onChange={(value): void => {
|
||||
setSelectedConfig((state) => ({
|
||||
...state,
|
||||
@@ -112,24 +114,37 @@ function FormAlertChannels({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('field_channel_type')} labelAlign="left" name="type">
|
||||
<Select disabled={editing} onChange={onTypeChangeHandler} value={type}>
|
||||
<Select.Option value="slack" key="slack">
|
||||
<Select
|
||||
disabled={editing}
|
||||
onChange={onTypeChangeHandler}
|
||||
value={type}
|
||||
data-testid="channel-type-select"
|
||||
>
|
||||
<Select.Option value="slack" key="slack" data-testid="select-option">
|
||||
Slack
|
||||
</Select.Option>
|
||||
<Select.Option value="webhook" key="webhook">
|
||||
<Select.Option value="webhook" key="webhook" data-testid="select-option">
|
||||
Webhook
|
||||
</Select.Option>
|
||||
<Select.Option value="pagerduty" key="pagerduty">
|
||||
<Select.Option
|
||||
value="pagerduty"
|
||||
key="pagerduty"
|
||||
data-testid="select-option"
|
||||
>
|
||||
Pagerduty
|
||||
</Select.Option>
|
||||
<Select.Option value="opsgenie" key="opsgenie">
|
||||
<Select.Option
|
||||
value="opsgenie"
|
||||
key="opsgenie"
|
||||
data-testid="select-option"
|
||||
>
|
||||
Opsgenie
|
||||
</Select.Option>
|
||||
<Select.Option value="email" key="email">
|
||||
<Select.Option value="email" key="email" data-testid="select-option">
|
||||
Email
|
||||
</Select.Option>
|
||||
{!isOssFeature?.active && (
|
||||
<Select.Option value="msteams" key="msteams">
|
||||
<Select.Option value="msteams" key="msteams" data-testid="select-option">
|
||||
<div>
|
||||
Microsoft Teams {!isUserOnEEPlan && '(Supported in Paid Plans Only)'}{' '}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import saveAlertApi from 'api/alerts/save';
|
||||
import testAlertApi from 'api/alerts/testAlert';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import { alertHelpMessage } from 'components/facingIssueBtn/util';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { alertHelpMessage } from 'components/LaunchChatSupport/util';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -712,7 +712,7 @@ function FormAlertRules({
|
||||
>
|
||||
Check an example alert
|
||||
</Button>
|
||||
<FacingIssueBtn
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
alert: alertDef?.alert,
|
||||
alertType: alertDef?.alertType,
|
||||
|
||||
@@ -34,6 +34,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isNil } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CalendarClock,
|
||||
@@ -605,243 +606,250 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
<div className="limits-data">
|
||||
<div className="signals">
|
||||
{SIGNALS.map((signal) => (
|
||||
<div className="signal" key={signal}>
|
||||
<div className="header">
|
||||
<div className="signal-name">{signal}</div>
|
||||
<div className="actions">
|
||||
{hasLimits(signal) ? (
|
||||
<>
|
||||
{SIGNALS.map((signal) => {
|
||||
const hasValidDayLimit = !isNil(limits[signal]?.config?.day?.size);
|
||||
const hasValidSecondLimit = !isNil(
|
||||
limits[signal]?.config?.second?.size,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="signal" key={signal}>
|
||||
<div className="header">
|
||||
<div className="signal-name">{signal}</div>
|
||||
<div className="actions">
|
||||
{hasLimits(signal) ? (
|
||||
<>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limits[signal]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limits[signal]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<PlusIcon size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limits[signal]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limits[signal]);
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signal,
|
||||
signal,
|
||||
config: {},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<PlusIcon size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
>
|
||||
Limits
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signal,
|
||||
signal,
|
||||
config: {},
|
||||
});
|
||||
<div className="signal-limit-values">
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal?.signal === signal &&
|
||||
isEditAddLimitOpen ? (
|
||||
<Form
|
||||
name="edit-ingestion-key-limit-form"
|
||||
key="addEditLimitForm"
|
||||
form={addEditLimitForm}
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
dailyLimit: bytesToGb(limits[signal]?.config?.day?.size),
|
||||
secondsLimit: bytesToGb(limits[signal]?.config?.second?.size),
|
||||
}}
|
||||
className="edit-ingestion-key-limit-form"
|
||||
>
|
||||
Limits
|
||||
</Button>
|
||||
<div className="signal-limit-edit-mode">
|
||||
<div className="daily-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Daily limit </div>
|
||||
<div className="subtitle">
|
||||
Add a limit for data ingested daily{' '}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="dailyLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="second-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Per Second limit </div>
|
||||
<div className="subtitle">
|
||||
{' '}
|
||||
Add a limit for data ingested every second{' '}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="secondsLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasCreateLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError?.error && (
|
||||
<div className="error">
|
||||
{createLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasUpdateLimitForIngestionKeyError &&
|
||||
updateLimitForIngestionKeyError && (
|
||||
<div className="error">
|
||||
{updateLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
isEditAddLimitOpen && (
|
||||
<div className="signal-limit-save-discard">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (!hasLimits(signal)) {
|
||||
handleAddLimit(APIKey, signal);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limits[signal]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={handleDiscardSaveLimit}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
) : (
|
||||
<div className="signal-limit-view-mode">
|
||||
<div className="signal-limit-value">
|
||||
<div className="limit-type">
|
||||
Daily <Minus size={16} />{' '}
|
||||
</div>
|
||||
|
||||
<div className="limit-value">
|
||||
{hasValidDayLimit ? (
|
||||
<>
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.metric?.day?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.config?.day?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="signal-limit-value">
|
||||
<div className="limit-type">
|
||||
Seconds <Minus size={16} />
|
||||
</div>
|
||||
|
||||
<div className="limit-value">
|
||||
{hasValidSecondLimit ? (
|
||||
<>
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.metric?.second?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.config?.second?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="signal-limit-values">
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal?.signal === signal &&
|
||||
isEditAddLimitOpen ? (
|
||||
<Form
|
||||
name="edit-ingestion-key-limit-form"
|
||||
key="addEditLimitForm"
|
||||
form={addEditLimitForm}
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
dailyLimit: bytesToGb(limits[signal]?.config?.day?.size),
|
||||
secondsLimit: bytesToGb(limits[signal]?.config?.second?.size),
|
||||
}}
|
||||
className="edit-ingestion-key-limit-form"
|
||||
>
|
||||
<div className="signal-limit-edit-mode">
|
||||
<div className="daily-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Daily limit </div>
|
||||
<div className="subtitle">
|
||||
Add a limit for data ingested daily{' '}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="dailyLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="second-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Per Second limit </div>
|
||||
<div className="subtitle">
|
||||
{' '}
|
||||
Add a limit for data ingested every second{' '}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="secondsLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasCreateLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError?.error && (
|
||||
<div className="error">
|
||||
{createLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasUpdateLimitForIngestionKeyError &&
|
||||
updateLimitForIngestionKeyError && (
|
||||
<div className="error">
|
||||
{updateLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signal &&
|
||||
isEditAddLimitOpen && (
|
||||
<div className="signal-limit-save-discard">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (!hasLimits(signal)) {
|
||||
handleAddLimit(APIKey, signal);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limits[signal]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn"
|
||||
size="small"
|
||||
disabled={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={handleDiscardSaveLimit}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
) : (
|
||||
<div className="signal-limit-view-mode">
|
||||
<div className="signal-limit-value">
|
||||
<div className="limit-type">
|
||||
Daily <Minus size={16} />{' '}
|
||||
</div>
|
||||
|
||||
<div className="limit-value">
|
||||
{limits[signal]?.config?.day?.size ? (
|
||||
<>
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.metric?.day?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.config?.day?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="signal-limit-value">
|
||||
<div className="limit-type">
|
||||
Seconds <Minus size={16} />
|
||||
</div>
|
||||
|
||||
<div className="limit-value">
|
||||
{limits[signal]?.config?.second?.size ? (
|
||||
<>
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.metric?.second?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{getYAxisFormattedValue(
|
||||
(limits[signal]?.config?.second?.size || 0).toString(),
|
||||
'bytes',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import saveAlertApi from 'api/alerts/save';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DropDown from 'components/DropDown/DropDown';
|
||||
import { listAlertMessage } from 'components/facingIssueBtn/util';
|
||||
import { listAlertMessage } from 'components/LaunchChatSupport/util';
|
||||
import {
|
||||
DynamicColumnsKey,
|
||||
TableDataSource,
|
||||
|
||||
@@ -43,10 +43,15 @@
|
||||
background: var(--bg-ink-400);
|
||||
cursor: pointer;
|
||||
|
||||
.dashboard-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.title-with-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
min-height: 24px;
|
||||
|
||||
@@ -55,6 +60,14 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 20px;
|
||||
width: 60%;
|
||||
|
||||
.dashboard-icon {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
min-height: 6px;
|
||||
@@ -62,6 +75,20 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
@@ -82,6 +109,45 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-with-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 40%;
|
||||
justify-content: flex-end;
|
||||
|
||||
.dashboard-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(173, 127, 88, 0.2);
|
||||
background: rgba(173, 127, 88, 0.1);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-action-icon {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
@@ -521,35 +587,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-with-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.dashboard-tags {
|
||||
display: flex;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(173, 127, 88, 0.2);
|
||||
background: rgba(173, 127, 88, 0.1);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.new-dashboard-menu {
|
||||
@@ -677,13 +714,13 @@
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
padding: 14px;
|
||||
padding: 8px;
|
||||
height: unset;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
@@ -702,12 +739,12 @@
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
padding: 14px;
|
||||
padding: 12px 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--bg-cherry-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
@@ -1048,6 +1085,10 @@
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.dashboard-title {
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.title-with-action {
|
||||
.dashboard-title {
|
||||
.ant-typography {
|
||||
@@ -1313,3 +1354,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title-toolip {
|
||||
.ant-tooltip-content {
|
||||
.ant-tooltip-inner {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import logEvent from 'api/common/logEvent';
|
||||
import createDashboard from 'api/dashboard/create';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import { dashboardListMessage } from 'components/facingIssueBtn/util';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { dashboardListMessage } from 'components/LaunchChatSupport/util';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Base64Icons } from 'container/NewDashboard/DashboardSettings/General/utils';
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
@@ -449,77 +449,94 @@ function DashboardsList(): JSX.Element {
|
||||
<div className="dashboard-list-item" onClick={onClickHandler}>
|
||||
<div className="title-with-action">
|
||||
<div className="dashboard-title">
|
||||
<img
|
||||
src={dashboard?.image || Base64Icons[0]}
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
alt="dashboard-image"
|
||||
/>
|
||||
<Typography.Text data-testid={`dashboard-title-${index}`}>
|
||||
{dashboard.name}
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={dashboard?.name?.length > 50 ? dashboard?.name : ''}
|
||||
placement="left"
|
||||
overlayClassName="title-toolip"
|
||||
>
|
||||
<Typography.Text data-testid={`dashboard-title-${index}`}>
|
||||
<Link to={getLink()} className="title">
|
||||
<img
|
||||
src={dashboard?.image || Base64Icons[0]}
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
alt="dashboard-image"
|
||||
className="dashboard-icon"
|
||||
/>
|
||||
{dashboard.name}
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="tags-with-actions">
|
||||
{dashboard?.tags && dashboard.tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{dashboard.tags.map((tag) => (
|
||||
{dashboard.tags.slice(0, 3).map((tag) => (
|
||||
<Tag className="tag" key={tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
|
||||
{dashboard.tags.length > 3 && (
|
||||
<Tag className="tag" key={dashboard.tags[3]}>
|
||||
+ <span> {dashboard.tags.length - 3} </span>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{action && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="dashboard-action-content">
|
||||
<section className="section-1">
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Expand size={14} />}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Link2 size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(`${window.location.origin}${getLink()}`);
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboard-actions"
|
||||
>
|
||||
<EllipsisVertical
|
||||
size={14}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="dashboard-action-content">
|
||||
<section className="section-1">
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Expand size={12} />}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="action-btn"
|
||||
icon={<Link2 size={12} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCopy(`${window.location.origin}${getLink()}`);
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<DeleteButton
|
||||
name={dashboard.name}
|
||||
id={dashboard.id}
|
||||
isLocked={dashboard.isLocked}
|
||||
createdBy={dashboard.createdBy}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="dashboard-actions"
|
||||
>
|
||||
<EllipsisVertical
|
||||
className="dashboard-action-icon"
|
||||
size={14}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-details">
|
||||
<div className="dashboard-created-at">
|
||||
@@ -647,7 +664,7 @@ function DashboardsList(): JSX.Element {
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage dashboards for your workspace.
|
||||
</Typography.Text>
|
||||
<FacingIssueBtn
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
screen: 'Dashboard list page',
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -46,6 +47,8 @@ export const useContextLogData = ({
|
||||
} => {
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
|
||||
const [lastLog, setLastLog] = useState<ILog>(log);
|
||||
|
||||
const orderByTimestamp = useMemo(() => getOrderByTimestamp(order), [order]);
|
||||
|
||||
const logsMorePageSize = useMemo(() => (page - 1) * LOGS_MORE_PAGE_SIZE, [
|
||||
@@ -71,11 +74,11 @@ export const useContextLogData = ({
|
||||
getRequestData({
|
||||
stagedQueryData: currentStagedQueryData,
|
||||
query,
|
||||
log,
|
||||
log: lastLog,
|
||||
orderByTimestamp,
|
||||
page,
|
||||
}),
|
||||
[currentStagedQueryData, page, log, query, orderByTimestamp],
|
||||
[currentStagedQueryData, query, lastLog, orderByTimestamp, page],
|
||||
);
|
||||
|
||||
const [requestData, setRequestData] = useState<Query | null>(
|
||||
@@ -95,8 +98,10 @@ export const useContextLogData = ({
|
||||
if (order === ORDERBY_FILTERS.ASC) {
|
||||
const reversedCurrentLogs = currentLogs.reverse();
|
||||
setLogs([...reversedCurrentLogs]);
|
||||
setLastLog(reversedCurrentLogs[0]);
|
||||
} else {
|
||||
setLogs([...currentLogs]);
|
||||
setLastLog(currentLogs[currentLogs.length - 1]);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -118,7 +123,7 @@ export const useContextLogData = ({
|
||||
const newRequestData = getRequestData({
|
||||
stagedQueryData: currentStagedQueryData,
|
||||
query,
|
||||
log,
|
||||
log: lastLog,
|
||||
orderByTimestamp,
|
||||
page: page + 1,
|
||||
pageSize: LOGS_MORE_PAGE_SIZE,
|
||||
@@ -131,6 +136,7 @@ export const useContextLogData = ({
|
||||
query,
|
||||
page,
|
||||
order,
|
||||
lastLog,
|
||||
currentStagedQueryData,
|
||||
isDisabledFetch,
|
||||
orderByTimestamp,
|
||||
@@ -142,7 +148,7 @@ export const useContextLogData = ({
|
||||
const newRequestData = getRequestData({
|
||||
stagedQueryData: currentStagedQueryData,
|
||||
query,
|
||||
log,
|
||||
log: lastLog,
|
||||
orderByTimestamp,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
@@ -23,6 +25,8 @@ import TableView from './TableView';
|
||||
interface OverviewProps {
|
||||
logData: ILog;
|
||||
isListViewPanel?: boolean;
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
@@ -34,6 +38,8 @@ function Overview({
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
@@ -200,6 +206,8 @@ function Overview({
|
||||
fieldSearchInput={fieldSearchInput}
|
||||
onClickActionItem={onClickActionItem}
|
||||
isListViewPanel={isListViewPanel}
|
||||
selectedOptions={selectedOptions}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -213,6 +221,7 @@ function Overview({
|
||||
|
||||
Overview.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
|
||||
@@ -6,17 +6,15 @@ import { LinkOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Space, Spin, Tooltip, Tree, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import AddToQueryHOC, {
|
||||
AddToQueryHOCProps,
|
||||
} from 'components/Logs/AddToQueryHOC';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
|
||||
@@ -29,12 +27,14 @@ import { generatePath } from 'react-router-dom';
|
||||
import { Dispatch } from 'redux';
|
||||
import AppActions from 'types/actions';
|
||||
import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { ActionItemProps } from './ActionItem';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
import {
|
||||
filterKeyForField,
|
||||
findKeyPath,
|
||||
flattenObject,
|
||||
jsonToDataNodes,
|
||||
recursiveParseJSON,
|
||||
@@ -47,7 +47,9 @@ const RESTRICTED_FIELDS = ['timestamp'];
|
||||
interface TableViewProps {
|
||||
logData: ILog;
|
||||
fieldSearchInput: string;
|
||||
selectedOptions: OptionsQuery;
|
||||
isListViewPanel?: boolean;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
}
|
||||
|
||||
type Props = TableViewProps &
|
||||
@@ -60,6 +62,8 @@ function TableView({
|
||||
onAddToQuery,
|
||||
onClickActionItem,
|
||||
isListViewPanel = false,
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
}: Props): JSX.Element | null {
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
@@ -71,21 +75,31 @@ function TableView({
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const pinnedAttributesFromLocalStorage = getLocalStorageApi(
|
||||
LOCALSTORAGE.PINNED_ATTRIBUTES,
|
||||
);
|
||||
const pinnedAttributes: Record<string, boolean> = {};
|
||||
|
||||
if (pinnedAttributesFromLocalStorage) {
|
||||
try {
|
||||
const parsedPinnedAttributes = JSON.parse(pinnedAttributesFromLocalStorage);
|
||||
setPinnedAttributes(parsedPinnedAttributes);
|
||||
} catch (e) {
|
||||
console.error('Error parsing pinned attributes from local storgage');
|
||||
}
|
||||
if (isListViewPanel) {
|
||||
listViewPanelSelectedFields?.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.name, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPinnedAttributes({});
|
||||
selectedOptions.selectColumns.forEach((val) => {
|
||||
const path = findKeyPath(logData, val.key, '');
|
||||
if (path) {
|
||||
pinnedAttributes[path] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
setPinnedAttributes(pinnedAttributes);
|
||||
}, [
|
||||
logData,
|
||||
selectedOptions.selectColumns,
|
||||
listViewPanelSelectedFields,
|
||||
isListViewPanel,
|
||||
]);
|
||||
|
||||
const flattenLogData: Record<string, string> | null = useMemo(
|
||||
() => (logData ? flattenObject(logData) : null),
|
||||
@@ -103,19 +117,6 @@ function TableView({
|
||||
}
|
||||
};
|
||||
|
||||
const togglePinAttribute = (record: DataType): void => {
|
||||
if (record) {
|
||||
const newPinnedAttributes = { ...pinnedAttributes };
|
||||
newPinnedAttributes[record.key] = !newPinnedAttributes[record.key];
|
||||
setPinnedAttributes(newPinnedAttributes);
|
||||
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.PINNED_ATTRIBUTES,
|
||||
JSON.stringify(newPinnedAttributes),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = (
|
||||
operator: string,
|
||||
fieldKey: string,
|
||||
@@ -201,11 +202,8 @@ function TableView({
|
||||
'pin-attribute-icon',
|
||||
pinnedAttributes[record?.key] ? 'pinned' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
togglePinAttribute(record);
|
||||
}}
|
||||
>
|
||||
<Pin size={14} color={pinColor} />
|
||||
{pinnedAttributes[record?.key] && <Pin size={14} color={pinColor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -380,6 +378,7 @@ function TableView({
|
||||
|
||||
TableView.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
listViewPanelSelectedFields: null,
|
||||
};
|
||||
|
||||
interface DataType {
|
||||
|
||||
@@ -188,6 +188,7 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
|
||||
attributes: {},
|
||||
resources: {},
|
||||
severity_text: logData.severity_text,
|
||||
severity_number: logData.severity_number,
|
||||
};
|
||||
|
||||
Object.keys(logData).forEach((key) => {
|
||||
@@ -266,3 +267,27 @@ export const removeEscapeCharacters = (str: string): string =>
|
||||
export function removeExtraSpaces(input: string): string {
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function findKeyPath(
|
||||
obj: AnyObject,
|
||||
targetKey: string,
|
||||
currentPath = '',
|
||||
): string | null {
|
||||
let finalPath = null;
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = obj[key];
|
||||
const newPath = currentPath ? `${currentPath}.${key}` : key;
|
||||
|
||||
if (key === targetKey) {
|
||||
finalPath = newPath;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const result = findKeyPath(value, targetKey, newPath);
|
||||
if (result) {
|
||||
finalPath = result;
|
||||
}
|
||||
}
|
||||
});
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
112
frontend/src/container/Login/__tests__/Login.test.tsx
Normal file
112
frontend/src/container/Login/__tests__/Login.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Login from 'container/Login';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
const errorNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
error: errorNotification,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Login Flow', () => {
|
||||
test('Login form is rendered correctly', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const headingElement = screen.getByRole('heading', {
|
||||
name: 'login_page_title',
|
||||
});
|
||||
expect(headingElement).toBeInTheDocument();
|
||||
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
expect(textboxElement).toBeInTheDocument();
|
||||
|
||||
const buttonElement = screen.getByRole('button', {
|
||||
name: 'button_initiate_login',
|
||||
});
|
||||
expect(buttonElement).toBeInTheDocument();
|
||||
|
||||
const noAccountPromptElement = screen.getByText('prompt_no_account');
|
||||
expect(noAccountPromptElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test(`Display "invalid_email" if email is not provided`, async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const buttonElement = screen.getByText('button_initiate_login');
|
||||
fireEvent.click(buttonElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'invalid_email',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Display invalid_config if invalid email is provided and next clicked', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
fireEvent.change(textboxElement, {
|
||||
target: { value: 'failEmail@signoz.io' },
|
||||
});
|
||||
|
||||
const buttonElement = screen.getByRole('button', {
|
||||
name: 'button_initiate_login',
|
||||
});
|
||||
fireEvent.click(buttonElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'invalid_config',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('providing shaheer@signoz.io as email and pressing next, should make the login_with_sso button visible', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('email'), {
|
||||
target: { value: 'shaheer@signoz.io' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('initiate_login'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('login_with_sso')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Display email, password, forgot password if password=Y', () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
|
||||
|
||||
const emailTextBox = screen.getByTestId('email');
|
||||
expect(emailTextBox).toBeInTheDocument();
|
||||
|
||||
const passwordTextBox = screen.getByTestId('password');
|
||||
expect(passwordTextBox).toBeInTheDocument();
|
||||
|
||||
const forgotPasswordLink = screen.getByText('forgot_password');
|
||||
expect(forgotPasswordLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Display tooltip with "prompt_forgot_password" if forgot password is clicked while password=Y', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
|
||||
const forgotPasswordLink = screen.getByText('forgot_password');
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseOver(forgotPasswordLink);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const forgotPasswordTooltip = screen.getByRole('tooltip', {
|
||||
name: 'prompt_forgot_password',
|
||||
});
|
||||
expect(forgotPasswordLink).toBeInTheDocument();
|
||||
expect(forgotPasswordTooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -163,8 +163,15 @@ function Login({
|
||||
response.payload.accessJwt,
|
||||
response.payload.refreshJwt,
|
||||
);
|
||||
if (history?.location?.state) {
|
||||
const historyState = history?.location?.state as any;
|
||||
|
||||
history.push(ROUTES.APPLICATION);
|
||||
if (historyState?.from) {
|
||||
history.push(historyState?.from);
|
||||
} else {
|
||||
history.push(ROUTES.APPLICATION);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notifications.error({
|
||||
message: response.error || t('unexpected_error'),
|
||||
@@ -213,6 +220,7 @@ function Login({
|
||||
<Input
|
||||
type="email"
|
||||
id="loginEmail"
|
||||
data-testid="email"
|
||||
required
|
||||
placeholder={t('placeholder_email')}
|
||||
autoFocus
|
||||
@@ -224,7 +232,12 @@ function Login({
|
||||
<ParentContainer>
|
||||
<Label htmlFor="Password">{t('label_password')}</Label>
|
||||
<FormContainer.Item name="password">
|
||||
<Input.Password required id="currentPassword" disabled={isLoading} />
|
||||
<Input.Password
|
||||
required
|
||||
id="currentPassword"
|
||||
data-testid="password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
<Tooltip title={t('prompt_forgot_password')}>
|
||||
<Typography.Link>{t('forgot_password')}</Typography.Link>
|
||||
@@ -243,6 +256,7 @@ function Login({
|
||||
loading={precheckInProcess}
|
||||
type="primary"
|
||||
onClick={onNextHandler}
|
||||
data-testid="initiate_login"
|
||||
>
|
||||
{t('button_initiate_login')}
|
||||
</Button>
|
||||
|
||||
@@ -245,6 +245,7 @@ function LogsPanelComponent({
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,12 +20,14 @@ import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { GraphTitle, SERVICE_CHART_ID } from '../constant';
|
||||
@@ -52,6 +54,11 @@ import {
|
||||
function Application(): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||
const servicename = decodeURIComponent(encodedServiceName);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
|
||||
const { search, pathname } = useLocation();
|
||||
const { queries } = useResourceAttribute();
|
||||
@@ -105,8 +112,13 @@ function Application(): JSX.Element {
|
||||
isLoading: topLevelOperationsIsLoading,
|
||||
isError: topLevelOperationsIsError,
|
||||
} = useQuery<ServiceDataProps>({
|
||||
queryKey: [servicename],
|
||||
queryFn: getTopLevelOperations,
|
||||
queryKey: [servicename, minTime, maxTime],
|
||||
queryFn: (): Promise<ServiceDataProps> =>
|
||||
getTopLevelOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
|
||||
@@ -164,6 +164,12 @@ function TopOperationsTable({
|
||||
|
||||
const downloadableData = convertedTracesToDownloadData(data);
|
||||
|
||||
const paginationConfig = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="top-operation">
|
||||
<div className="top-operation--download">
|
||||
@@ -181,6 +187,7 @@ function TopOperationsTable({
|
||||
tableLayout="fixed"
|
||||
dataSource={data}
|
||||
rowKey="name"
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -90,18 +90,23 @@ function PasswordContainer(): JSX.Element {
|
||||
return (
|
||||
<Card>
|
||||
<Space direction="vertical" size="small">
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
<Typography.Title
|
||||
level={4}
|
||||
style={{ marginTop: 0 }}
|
||||
data-testid="change-password-header"
|
||||
>
|
||||
{t('change_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography.Title>
|
||||
<Space direction="vertical">
|
||||
<Typography>
|
||||
<Typography data-testid="current-password-label">
|
||||
{t('current_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography>
|
||||
<Password
|
||||
data-testid="current-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
@@ -111,12 +116,13 @@ function PasswordContainer(): JSX.Element {
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical">
|
||||
<Typography>
|
||||
<Typography data-testid="new-password-label">
|
||||
{t('new_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
</Typography>
|
||||
<Password
|
||||
data-testid="new-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
@@ -129,6 +135,7 @@ function PasswordContainer(): JSX.Element {
|
||||
<Space>
|
||||
{isPasswordPolicyError && (
|
||||
<Typography.Paragraph
|
||||
data-testid="validation-message"
|
||||
style={{
|
||||
color: '#D89614',
|
||||
marginTop: '0.50rem',
|
||||
@@ -143,8 +150,13 @@ function PasswordContainer(): JSX.Element {
|
||||
loading={isLoading}
|
||||
onClick={onChangePasswordClickHandler}
|
||||
type="primary"
|
||||
data-testid="update-password-button"
|
||||
>
|
||||
<Save size={12} style={{ marginRight: '8px' }} />{' '}
|
||||
<Save
|
||||
size={12}
|
||||
style={{ marginRight: '8px' }}
|
||||
data-testid="update-password-icon"
|
||||
/>{' '}
|
||||
{t('change_password', {
|
||||
ns: 'settings',
|
||||
})}
|
||||
|
||||
@@ -86,8 +86,11 @@ function UserInfo(): JSX.Element {
|
||||
|
||||
<Flex gap={16}>
|
||||
<Space>
|
||||
<Typography className="userInfo-label">Name</Typography>
|
||||
<Typography className="userInfo-label" data-testid="name-label">
|
||||
Name
|
||||
</Typography>
|
||||
<NameInput
|
||||
data-testid="name-textbox"
|
||||
placeholder="Your Name"
|
||||
onChange={(event): void => {
|
||||
setChangedName(event.target.value);
|
||||
@@ -102,6 +105,7 @@ function UserInfo(): JSX.Element {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={onClickUpdateHandler}
|
||||
data-testid="update-name-button"
|
||||
type="primary"
|
||||
>
|
||||
<PencilIcon size={12} /> Update
|
||||
@@ -109,13 +113,29 @@ function UserInfo(): JSX.Element {
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<Typography className="userInfo-label"> Email </Typography>
|
||||
<Input className="userInfo-value" value={user.email} disabled />
|
||||
<Typography className="userInfo-label" data-testid="email-label">
|
||||
{' '}
|
||||
Email{' '}
|
||||
</Typography>
|
||||
<Input
|
||||
className="userInfo-value"
|
||||
data-testid="email-textbox"
|
||||
value={user.email}
|
||||
disabled
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Typography className="userInfo-label"> Role </Typography>
|
||||
<Input className="userInfo-value" value={role || ''} disabled />
|
||||
<Typography className="userInfo-label" data-testid="role-label">
|
||||
{' '}
|
||||
Role{' '}
|
||||
</Typography>
|
||||
<Input
|
||||
className="userInfo-value"
|
||||
value={role || ''}
|
||||
disabled
|
||||
data-testid="role-textbox"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
219
frontend/src/container/MySettings/__tests__/MySettings.test.tsx
Normal file
219
frontend/src/container/MySettings/__tests__/MySettings.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
useIsDarkMode: jest.fn(() => ({
|
||||
toggleTheme: toggleThemeFunction,
|
||||
})),
|
||||
default: jest.fn(() => ({
|
||||
toggleTheme: toggleThemeFunction,
|
||||
})),
|
||||
}));
|
||||
|
||||
const errorNotification = jest.fn();
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
error: errorNotification,
|
||||
success: successNotification,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
enum ThemeOptions {
|
||||
Dark = 'Dark',
|
||||
Light = 'Light Beta',
|
||||
}
|
||||
|
||||
describe('MySettings Flows', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
render(<MySettingsContainer />);
|
||||
});
|
||||
|
||||
describe('Dark/Light Theme Switch', () => {
|
||||
it('Should display Dark and Light theme buttons properly', async () => {
|
||||
expect(screen.getByText('Dark')).toBeInTheDocument();
|
||||
|
||||
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
|
||||
expect(darkThemeIcon).toBeInTheDocument();
|
||||
expect(darkThemeIcon.tagName).toBe('svg');
|
||||
|
||||
expect(screen.getByText('Light')).toBeInTheDocument();
|
||||
const lightThemeIcon = screen.getByTestId('light-theme-icon');
|
||||
expect(lightThemeIcon).toBeInTheDocument();
|
||||
expect(lightThemeIcon.tagName).toBe('svg');
|
||||
});
|
||||
|
||||
it('Should activate Dark and Light buttons on click', async () => {
|
||||
const initialSelectedOption = screen.getByRole('radio', {
|
||||
name: ThemeOptions.Dark,
|
||||
});
|
||||
expect(initialSelectedOption).toBeChecked();
|
||||
|
||||
const newThemeOption = screen.getByRole('radio', {
|
||||
name: ThemeOptions.Light,
|
||||
});
|
||||
fireEvent.click(newThemeOption);
|
||||
|
||||
expect(newThemeOption).toBeChecked();
|
||||
});
|
||||
|
||||
it('Should switch the them on clicking Light theme', async () => {
|
||||
const lightThemeOption = screen.getByRole('radio', {
|
||||
name: /light/i,
|
||||
});
|
||||
fireEvent.click(lightThemeOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggleThemeFunction).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Details Form', () => {
|
||||
it('Should properly display the User Details Form', () => {
|
||||
const userDetailsHeader = screen.getByRole('heading', {
|
||||
name: /user details/i,
|
||||
});
|
||||
const nameLabel = screen.getByTestId('name-label');
|
||||
const nameTextbox = screen.getByTestId('name-textbox');
|
||||
const updateNameButton = screen.getByTestId('update-name-button');
|
||||
const emailLabel = screen.getByTestId('email-label');
|
||||
const emailTextbox = screen.getByTestId('email-textbox');
|
||||
const roleLabel = screen.getByTestId('role-label');
|
||||
const roleTextbox = screen.getByTestId('role-textbox');
|
||||
|
||||
expect(userDetailsHeader).toBeInTheDocument();
|
||||
expect(nameLabel).toBeInTheDocument();
|
||||
expect(nameTextbox).toBeInTheDocument();
|
||||
expect(updateNameButton).toBeInTheDocument();
|
||||
expect(emailLabel).toBeInTheDocument();
|
||||
expect(emailTextbox).toBeInTheDocument();
|
||||
expect(roleLabel).toBeInTheDocument();
|
||||
expect(roleTextbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should update the name on clicking Update button', async () => {
|
||||
const nameTextbox = screen.getByTestId('name-textbox');
|
||||
const updateNameButton = screen.getByTestId('update-name-button');
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(nameTextbox, { target: { value: 'New Name' } });
|
||||
});
|
||||
|
||||
fireEvent.click(updateNameButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset password', () => {
|
||||
let currentPasswordTextbox: Node | Window;
|
||||
let newPasswordTextbox: Node | Window;
|
||||
let submitButtonElement: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
currentPasswordTextbox = screen.getByTestId('current-password-textbox');
|
||||
newPasswordTextbox = screen.getByTestId('new-password-textbox');
|
||||
submitButtonElement = screen.getByTestId('update-password-button');
|
||||
});
|
||||
|
||||
it('Should properly display the Password Reset Form', () => {
|
||||
const passwordResetHeader = screen.getByTestId('change-password-header');
|
||||
expect(passwordResetHeader).toBeInTheDocument();
|
||||
|
||||
const currentPasswordLabel = screen.getByTestId('current-password-label');
|
||||
expect(currentPasswordLabel).toBeInTheDocument();
|
||||
|
||||
expect(currentPasswordTextbox).toBeInTheDocument();
|
||||
|
||||
const newPasswordLabel = screen.getByTestId('new-password-label');
|
||||
expect(newPasswordLabel).toBeInTheDocument();
|
||||
|
||||
expect(newPasswordTextbox).toBeInTheDocument();
|
||||
expect(submitButtonElement).toBeInTheDocument();
|
||||
|
||||
const savePasswordIcon = screen.getByTestId('update-password-icon');
|
||||
expect(savePasswordIcon).toBeInTheDocument();
|
||||
expect(savePasswordIcon.tagName).toBe('svg');
|
||||
});
|
||||
|
||||
it('Should display validation error if password is less than 8 characters', async () => {
|
||||
const currentPasswordTextbox = screen.getByTestId(
|
||||
'current-password-textbox',
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.change(currentPasswordTextbox, { target: { value: '123' } });
|
||||
});
|
||||
const validationMessage = await screen.findByTestId('validation-message');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(validationMessage).toHaveTextContent(
|
||||
'Password must a have minimum of 8 characters',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Should display 'inavlid credentials' error if different current and new passwords are provided", async () => {
|
||||
act(() => {
|
||||
fireEvent.change(currentPasswordTextbox, {
|
||||
target: { value: '123456879' },
|
||||
});
|
||||
|
||||
fireEvent.change(newPasswordTextbox, { target: { value: '123456789' } });
|
||||
});
|
||||
|
||||
fireEvent.click(submitButtonElement);
|
||||
|
||||
await waitFor(() => expect(errorNotification).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('Should check if the "Change Password" button is disabled in case current / new password is less than 8 characters', () => {
|
||||
act(() => {
|
||||
fireEvent.change(currentPasswordTextbox, {
|
||||
target: { value: '123' },
|
||||
});
|
||||
fireEvent.change(newPasswordTextbox, { target: { value: '123' } });
|
||||
});
|
||||
|
||||
expect(submitButtonElement).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Should check if 'Change Password' button is enabled when password is at least 8 characters ", async () => {
|
||||
expect(submitButtonElement).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(currentPasswordTextbox, {
|
||||
target: { value: '123456789' },
|
||||
});
|
||||
fireEvent.change(newPasswordTextbox, { target: { value: '1234567890' } });
|
||||
});
|
||||
|
||||
expect(submitButtonElement).toBeEnabled();
|
||||
});
|
||||
|
||||
test("Should check if 'Change Password' button is disabled when current and new passwords are the same ", async () => {
|
||||
expect(submitButtonElement).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(currentPasswordTextbox, {
|
||||
target: { value: '123456789' },
|
||||
});
|
||||
fireEvent.change(newPasswordTextbox, { target: { value: '123456789' } });
|
||||
});
|
||||
|
||||
expect(submitButtonElement).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import './MySettings.styles.scss';
|
||||
|
||||
import { Button, Radio, RadioChangeEvent, Space, Typography } from 'antd';
|
||||
import { Button, Radio, RadioChangeEvent, Space, Tag, Typography } from 'antd';
|
||||
import { Logout } from 'api/utils';
|
||||
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
@@ -17,7 +17,7 @@ function MySettings(): JSX.Element {
|
||||
{
|
||||
label: (
|
||||
<div className="theme-option">
|
||||
<Moon size={12} /> Dark{' '}
|
||||
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
|
||||
</div>
|
||||
),
|
||||
value: 'dark',
|
||||
@@ -25,7 +25,8 @@ function MySettings(): JSX.Element {
|
||||
{
|
||||
label: (
|
||||
<div className="theme-option">
|
||||
<Sun size={12} /> Light{' '}
|
||||
<Sun size={12} data-testid="light-theme-icon" /> Light{' '}
|
||||
<Tag color="magenta">Beta</Tag>
|
||||
</div>
|
||||
),
|
||||
value: 'light',
|
||||
@@ -63,6 +64,7 @@ function MySettings(): JSX.Element {
|
||||
value={theme}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
data-testid="theme-selector"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +76,12 @@ function MySettings(): JSX.Element {
|
||||
<Password />
|
||||
</div>
|
||||
|
||||
<Button className="flexBtn" onClick={(): void => Logout()} type="primary">
|
||||
<Button
|
||||
className="flexBtn"
|
||||
onClick={(): void => Logout()}
|
||||
type="primary"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<LogOut size={12} /> Logout
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -59,13 +59,16 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-right: 16px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
@@ -99,6 +102,14 @@
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
@@ -110,14 +121,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dashbord-details {
|
||||
.dashboard-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
padding: 10px 0px 0px 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
|
||||
.dashboard-title {
|
||||
color: #fff;
|
||||
@@ -128,13 +145,23 @@
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
flex-shrink: 0;
|
||||
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.right-section {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 10px 16px 0px 0px;
|
||||
gap: 14px;
|
||||
|
||||
.icons {
|
||||
@@ -199,6 +226,8 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
@@ -219,7 +248,6 @@
|
||||
}
|
||||
}
|
||||
.dashboard-description-section {
|
||||
max-width: 957px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -578,7 +606,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dashbord-details {
|
||||
.dashboard-details {
|
||||
.left-section {
|
||||
.dashboard-title {
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { getNonIntegrationDashboardById } from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DashboardDescription from '..';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/TopNav/DateTimeSelectionV2/index.tsx',
|
||||
() =>
|
||||
function MockDateTimeSelection(): JSX.Element {
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
},
|
||||
);
|
||||
|
||||
describe('Dashboard landing page actions header tests', () => {
|
||||
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
enter: (): Promise<void> => Promise.resolve(),
|
||||
exit: (): Promise<void> => Promise.resolve(),
|
||||
node: { current: null },
|
||||
}}
|
||||
/>
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
|
||||
await fireEvent.click(dashboardSettingsTrigger);
|
||||
|
||||
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).toBeDisabled());
|
||||
});
|
||||
it('unlock dashboard should not be disabled for non integration created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/dashboards/4', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(getNonIntegrationDashboardById)),
|
||||
),
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
enter: (): Promise<void> => Promise.resolve(),
|
||||
exit: (): Promise<void> => Promise.resolve(),
|
||||
node: { current: null },
|
||||
}}
|
||||
/>
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
|
||||
await fireEvent.click(dashboardSettingsTrigger);
|
||||
|
||||
const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard');
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import './Description.styles.scss';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
Popover,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import { dashboardHelpMessage } from 'components/facingIssueBtn/util';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { dashboardHelpMessage } from 'components/LaunchChatSupport/util';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -285,34 +294,35 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="id-btn"
|
||||
icon={
|
||||
// eslint-disable-next-line jsx-a11y/img-redundant-alt
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-image"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
<section className="dashbord-details">
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-img"
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
/>
|
||||
<Typography.Text className="dashboard-title">{title}</Typography.Text>
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-img"
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
/>{' '}
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
{isDashboardLocked && <LockKeyhole size={14} />}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<FacingIssueBtn
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: updatedTitle,
|
||||
@@ -334,13 +344,22 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || role === USER_ROLES.ADMIN) && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
onClick={handleLockDashboardToggle}
|
||||
<Tooltip
|
||||
title={
|
||||
selectedDashboard?.created_by === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={selectedDashboard?.created_by === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Tabs, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
@@ -237,7 +237,7 @@ function QuerySection({
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<FacingIssueBtn
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: selectedDashboard?.data.title,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
.y-axis-unit-selector {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
|
||||
.heading {
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ export function ColumnUnitSelector(
|
||||
|
||||
function getAggregateColumnsNamesAndLabels(): string[] {
|
||||
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
|
||||
return currentQuery.builder.queryData.map((q) => q.queryName);
|
||||
const queries = currentQuery.builder.queryData.map((q) => q.queryName);
|
||||
const formulas = currentQuery.builder.queryFormulas.map((q) => q.queryName);
|
||||
return [...queries, ...formulas];
|
||||
}
|
||||
if (currentQuery.queryType === EQueryType.CLICKHOUSE) {
|
||||
return currentQuery.clickhouse_sql.map((q) => q.name);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ColumnUnitSelector } from '../ColumnUnitSelector';
|
||||
|
||||
const compositeQueryParam = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'metrics',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
key: 'signoz_latency',
|
||||
dataType: 'float64',
|
||||
type: 'ExponentialHistogram',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
id: 'signoz_latency--float64--ExponentialHistogram--true',
|
||||
},
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p90',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: 'service_name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
id: 'service_name--string--tag--false',
|
||||
},
|
||||
],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [
|
||||
{
|
||||
queryName: 'F1',
|
||||
expression: 'A * 10',
|
||||
disabled: false,
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
promql: [
|
||||
{
|
||||
name: 'A',
|
||||
query: '',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
clickhouse_sql: [
|
||||
{
|
||||
name: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '12e1d311-cb47-4b76-af68-65d8e85c9e0d',
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetCompositeQueryParam', () => ({
|
||||
useGetCompositeQueryParam: (): Query => compositeQueryParam as Query,
|
||||
}));
|
||||
|
||||
describe('Column unit selector panel unit test', () => {
|
||||
it('unit selectors should be rendered for queries and formula', () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD_WIDGET}/`,
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByText } = render(
|
||||
<QueryBuilderProvider>
|
||||
<ColumnUnitSelector columnUnits={{}} setColumnUnits={(): void => {}} />,
|
||||
</QueryBuilderProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('F1')).toBeInTheDocument();
|
||||
expect(getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,10 @@ export const timeItems: timePreferance[] = [
|
||||
name: 'Last 1 week',
|
||||
enum: 'LAST_1_WEEK',
|
||||
},
|
||||
{
|
||||
name: 'Last 1 month',
|
||||
enum: 'LAST_1_MONTH',
|
||||
},
|
||||
];
|
||||
|
||||
export interface timePreferance {
|
||||
@@ -52,7 +56,8 @@ export type timePreferenceType =
|
||||
| LAST_6_HR
|
||||
| LAST_1_DAY
|
||||
| LAST_3_DAYS
|
||||
| LAST_1_WEEK;
|
||||
| LAST_1_WEEK
|
||||
| LAST_1_MONTH;
|
||||
|
||||
type GLOBAL_TIME = 'GLOBAL_TIME';
|
||||
type LAST_5_MIN = 'LAST_5_MIN';
|
||||
@@ -63,5 +68,6 @@ type LAST_6_HR = 'LAST_6_HR';
|
||||
type LAST_1_DAY = 'LAST_1_DAY';
|
||||
type LAST_3_DAYS = 'LAST_3_DAYS';
|
||||
type LAST_1_WEEK = 'LAST_1_WEEK';
|
||||
type LAST_1_MONTH = 'LAST_1_MONTH';
|
||||
|
||||
export default timeItems;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
listViewInitialTraceQuery,
|
||||
PANEL_TYPES_INITIAL_QUERY,
|
||||
} from 'container/NewDashboard/ComponentsSlider/constants';
|
||||
import { isEqual, set, unset } from 'lodash-es';
|
||||
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -43,54 +43,59 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'queryName',
|
||||
'expression',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'queryName',
|
||||
'expression',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -99,54 +104,59 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'queryName',
|
||||
'expression',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'queryName',
|
||||
'expression',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -155,48 +165,59 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'disabled',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -205,18 +226,18 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'reduceTo',
|
||||
'legend',
|
||||
],
|
||||
},
|
||||
@@ -224,37 +245,40 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'reduceTo',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'reduceTo',
|
||||
'legend',
|
||||
'functions',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'reduceTo',
|
||||
'legend',
|
||||
],
|
||||
},
|
||||
@@ -264,17 +288,18 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
],
|
||||
},
|
||||
@@ -282,35 +307,40 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'reduceTo',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
'functions',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
],
|
||||
},
|
||||
@@ -319,7 +349,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.LIST]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: ['filters', 'limit', 'orderBy'],
|
||||
queryData: ['filters', 'limit', 'orderBy', 'functions'],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
@@ -329,7 +359,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: ['filters', 'limit', 'orderBy'],
|
||||
queryData: ['filters', 'limit', 'orderBy', 'functions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,12 +367,13 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'reduceTo',
|
||||
'having',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
@@ -353,30 +384,32 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'spaceAggregation',
|
||||
'having',
|
||||
'reduceTo',
|
||||
'functions',
|
||||
'spaceAggregation',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
'functions',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'filters',
|
||||
'aggregateOperator',
|
||||
'aggregateAttribute',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'reduceTo',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
@@ -396,12 +429,8 @@ export function handleQueryChange(
|
||||
builder: {
|
||||
...supersetQuery.builder,
|
||||
queryData: supersetQuery.builder.queryData.map((query, index) => {
|
||||
const { dataSource, expression, queryName } = query;
|
||||
const tempQuery = {
|
||||
...initialQueryBuilderFormValuesMap[dataSource],
|
||||
expression,
|
||||
queryName,
|
||||
};
|
||||
const { dataSource } = query;
|
||||
const tempQuery = cloneDeep(initialQueryBuilderFormValuesMap[dataSource]);
|
||||
|
||||
const fieldsToSelect =
|
||||
panelTypeDataSourceFormValuesMap[newPanelType][dataSource].builder
|
||||
@@ -416,6 +445,8 @@ export function handleQueryChange(
|
||||
set(tempQuery, 'offset', 0);
|
||||
set(tempQuery, 'pageSize', 10);
|
||||
} else if (tempQuery.aggregateOperator === 'noop') {
|
||||
// this condition takes care of the part where we start with the list panel type and then shift to other panels
|
||||
// because in other cases we never set list operator and other fields in superset query rather just update in the current / staged query
|
||||
set(tempQuery, 'aggregateOperator', 'count');
|
||||
unset(tempQuery, 'offset');
|
||||
unset(tempQuery, 'pageSize');
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
## Install otel-collector in your Kubernetes infra
|
||||
|
||||
### Install otel-collector in your Kubernetes infra
|
||||
|
||||
Add the SigNoz Helm Chart repository
|
||||
```bash
|
||||
helm repo add signoz https://charts.signoz.io
|
||||
```
|
||||
|
||||
|
||||
|
||||
If the chart is already present, update the chart to the latest using:
|
||||
```bash
|
||||
helm repo update
|
||||
```
|
||||
|
||||
|
||||
|
||||
Install the Kubernetes Infrastructure chart provided by SigNoz
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra \
|
||||
--set otelCollectorEndpoint=ingest.{{REGION}}.signoz.cloud:443 \
|
||||
--set otelInsecure=false \
|
||||
--set signozApiKey={{SIGNOZ_INGESTION_KEY}} \
|
||||
--set global.clusterName=<CLUSTER_NAME>
|
||||
For generic Kubernetes clusters, you can create *override-values.yaml* with the following configuration:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
cloud: others
|
||||
clusterName: <CLUSTER_NAME>
|
||||
deploymentEnvironment: <DEPLOYMENT_ENVIRONMENT>
|
||||
otelCollectorEndpoint: ingest.{{REGION}}.signoz.cloud:443
|
||||
otelInsecure: false
|
||||
signozApiKey: {{SIGNOZ_INGESTION_KEY}}
|
||||
presets:
|
||||
otlpExporter:
|
||||
enabled: true
|
||||
loggingExporter:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
- Replace `<CLUSTER_NAME>` with the name of the Kubernetes cluster or a unique identifier of the cluster.
|
||||
- Replace `<DEPLOYMENT_ENVIRONMENT>` with the deployment environment of your application. Example: **"staging"**, **"production"**, etc.
|
||||
|
||||
|
||||
|
||||
To install the k8s-infra chart with the above configuration, run the following command:
|
||||
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra -f override-values.yaml
|
||||
```
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
**Step 1: Installing the OpenTelemetry dependency packages:**
|
||||
|
||||
```bash
|
||||
dotnet add package OpenTelemetry
|
||||
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
|
||||
dotnet add package OpenTelemetry.Extensions.Hosting
|
||||
dotnet add package OpenTelemetry.Instrumentation.Runtime
|
||||
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
|
||||
dotnet add package OpenTelemetry.AutoInstrumentation
|
||||
```
|
||||
|
||||
**Step 2: Adding OpenTelemetry as a service and configuring exporter options in `Program.cs`:**
|
||||
|
||||
In your `Program.cs` file, add OpenTelemetry as a service.
|
||||
|
||||
Here’s a sample `Program.cs` file with the configured variables.
|
||||
|
||||
```bash
|
||||
using System.Diagnostics;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure OpenTelemetry with tracing and auto-start.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource =>
|
||||
resource.AddService(serviceName: "{{MYAPP}}"))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddOtlpExporter(otlpOptions =>
|
||||
{
|
||||
//SigNoz Cloud Endpoint
|
||||
otlpOptions.Endpoint = new Uri("https://ingest.{{REGION}}.signoz.cloud:443");
|
||||
|
||||
otlpOptions.Protocol = OtlpExportProtocol.Grpc;
|
||||
|
||||
//SigNoz Cloud account Ingestion key
|
||||
string headerKey = "signoz-access-token";
|
||||
string headerValue = "{{SIGNOZ_INGESTION_KEY}}";
|
||||
|
||||
string formattedHeader = $"{headerKey}={headerValue}";
|
||||
otlpOptions.Headers = formattedHeader;
|
||||
}));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// The index route ("/") is set up to write out the OpenTelemetry trace information on the response:
|
||||
app.MapGet("/", () => $"Hello World! OpenTelemetry Trace: {Activity.Current?.Id}");
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
|
||||
**Step 3. Running the .NET application:**
|
||||
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet run
|
||||
```
|
||||
|
||||
**Step 4: Generating some load data and checking your application in SigNoz UI**
|
||||
|
||||
Once your application is running, generate some traffic by interacting with it.
|
||||
|
||||
In the SigNoz account, open the `Services` tab. Hit the `Refresh` button on the top right corner, and your application should appear in the list of `Applications`. Ensure that you're checking data for the `time range filter` applied in the top right corner. You might have to wait for a few seconds before the data appears on SigNoz UI.
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
To run your .NET application, use the below command :
|
||||
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Once you run your .NET application, interact with your application to generate some load and see your application in the SigNoz UI.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
## Setup OpenTelemetry Binary as an agent
|
||||
|
||||
|
||||
|
||||
As a first step, you should install the OTel collector Binary according to the instructions provided on [this link](https://signoz.io/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/).
|
||||
|
||||
|
||||
|
||||
Once you are done setting up the OTel collector binary, you can follow the next steps.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
After setting up the Otel collector agent, follow the steps below to instrument your .NET Application
|
||||
|
||||
|
||||
|
||||
|
||||
### Step 1: Install OpenTelemetry Dependencies
|
||||
Install the following dependencies in your application.
|
||||
|
||||
```bash
|
||||
dotnet add package OpenTelemetry
|
||||
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
|
||||
dotnet add package OpenTelemetry.Extensions.Hosting
|
||||
dotnet add package OpenTelemetry.Instrumentation.Runtime
|
||||
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
|
||||
dotnet add package OpenTelemetry.AutoInstrumentation
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Step 2: Adding OpenTelemetry as a service and configuring exporter options
|
||||
|
||||
In your `Program.cs` file, add OpenTelemetry as a service. Here, we are configuring these variables:
|
||||
|
||||
|
||||
|
||||
Here’s a sample `Program.cs` file with the configured variables:
|
||||
|
||||
```bash
|
||||
using System.Diagnostics;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure OpenTelemetry with tracing and auto-start.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource =>
|
||||
resource.AddService(serviceName: "{{MYAPP}}"))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddOtlpExporter(otlpOptions =>
|
||||
{
|
||||
otlpOptions.Endpoint = new Uri("http://localhost:4317");
|
||||
|
||||
otlpOptions.Protocol = OtlpExportProtocol.Grpc;
|
||||
}));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
//The index route ("/") is set up to write out the OpenTelemetry trace information on the response:
|
||||
app.MapGet("/", () => $"Hello World! OpenTelemetry Trace: {Activity.Current?.Id}");
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
|
||||
The OpenTelemetry.Exporter.Options get or set the target to which the exporter is going to send traces. Here, we’re configuring it to send traces to the OTel Collector agent. The target must be a valid Uri with the scheme (http or https) and host and may contain a port and a path.
|
||||
|
||||
This is done by configuring an OpenTelemetry [TracerProvider](https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/docs/trace/customizing-the-sdk#readme) using extension methods and setting it to auto-start when the host is started.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
Once you are done intrumenting your .NET application, you can run it using the below commands
|
||||
|
||||
|
||||
### Step 1: Run OTel Collector
|
||||
Run this command inside the `otelcol-contrib` directory that you created in the install Otel Collector step
|
||||
|
||||
```bash
|
||||
./otelcol-contrib --config ./config.yaml
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Step 2: Run your .NET application
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,24 +1,43 @@
|
||||
## Install otel-collector in your Kubernetes infra
|
||||
|
||||
### Install otel-collector in your Kubernetes infra
|
||||
|
||||
Add the SigNoz Helm Chart repository
|
||||
```bash
|
||||
helm repo add signoz https://charts.signoz.io
|
||||
```
|
||||
|
||||
|
||||
|
||||
If the chart is already present, update the chart to the latest using:
|
||||
```bash
|
||||
helm repo update
|
||||
```
|
||||
|
||||
|
||||
|
||||
Install the Kubernetes Infrastructure chart provided by SigNoz
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra \
|
||||
--set otelCollectorEndpoint=ingest.{{REGION}}.signoz.cloud:443 \
|
||||
--set otelInsecure=false \
|
||||
--set signozApiKey={{SIGNOZ_INGESTION_KEY}} \
|
||||
--set global.clusterName=<CLUSTER_NAME>
|
||||
For generic Kubernetes clusters, you can create *override-values.yaml* with the following configuration:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
cloud: others
|
||||
clusterName: <CLUSTER_NAME>
|
||||
deploymentEnvironment: <DEPLOYMENT_ENVIRONMENT>
|
||||
otelCollectorEndpoint: ingest.{{REGION}}.signoz.cloud:443
|
||||
otelInsecure: false
|
||||
signozApiKey: {{SIGNOZ_INGESTION_KEY}}
|
||||
presets:
|
||||
otlpExporter:
|
||||
enabled: true
|
||||
loggingExporter:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
- Replace `<CLUSTER_NAME>` with the name of the Kubernetes cluster or a unique identifier of the cluster.
|
||||
- Replace `<DEPLOYMENT_ENVIRONMENT>` with the deployment environment of your application. Example: **"staging"**, **"production"**, etc.
|
||||
|
||||
|
||||
|
||||
To install the k8s-infra chart with the above configuration, run the following command:
|
||||
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra -f override-values.yaml
|
||||
```
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
|
||||
Follow the steps below to instrument your Elixir (Phoenix + Ecto) Application
|
||||
|
||||
### Step 1: Add dependencies
|
||||
Install dependencies related to OpenTelemetry by adding them to `mix.exs` file
|
||||
|
||||
```bash
|
||||
{:opentelemetry_exporter, "~> 1.6"},
|
||||
{:opentelemetry_api, "~> 1.2"},
|
||||
{:opentelemetry, "~> 1.3"},
|
||||
{:opentelemetry_semantic_conventions, "~> 0.2"},
|
||||
{:opentelemetry_cowboy, "~> 0.2.1"},
|
||||
{:opentelemetry_phoenix, "~> 1.1"},
|
||||
{:opentelemetry_ecto, "~> 1.1"}
|
||||
```
|
||||
|
||||
|
||||
In your application start, usually the `application.ex` file, setup the telemetry handlers
|
||||
|
||||
```bash
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:{{MYAPP}}, :repo])
|
||||
```
|
||||
|
||||
|
||||
As an example, this is how you can setup the handlers in your application.ex file for an application called demo :
|
||||
|
||||
```bash
|
||||
# application.ex
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:demo, :repo])
|
||||
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Step 2: Configure Application
|
||||
You need to configure your application to send telemetry data by adding the following config to your `runtime.exs` file:
|
||||
|
||||
```bash
|
||||
config :opentelemetry, :resource, service: %{name: "{{MYAPP}}"}
|
||||
|
||||
config :opentelemetry, :processors,
|
||||
otel_batch_processor: %{
|
||||
exporter: {
|
||||
:opentelemetry_exporter,
|
||||
%{
|
||||
endpoints: ["https://ingest.{{REGION}}.signoz.cloud:443"],
|
||||
headers: [
|
||||
{"signoz-access-token", {{SIGNOZ_ACCESS_TOKEN}} }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
### Running your Elixir application
|
||||
Once you are done instrumenting your Elixir (Phoenix + Ecto) application with OpenTelemetry, you should install the dependencies needed to run your application and run it as you normally would.
|
||||
|
||||
|
||||
|
||||
To see some examples for instrumented applications, you can checkout [this link](https://signoz.io/docs/instrumentation/elixir/#sample-examples)
|
||||
@@ -0,0 +1,105 @@
|
||||
## Setup OpenTelemetry Binary as an agent
|
||||
|
||||
|
||||
### Step 1: Download otel-collector tar.gz
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v{{OTEL_VERSION}}/otelcol-contrib_{{OTEL_VERSION}}_linux_amd64.tar.gz
|
||||
```
|
||||
|
||||
|
||||
### Step 2: Extract otel-collector tar.gz to the `otelcol-contrib` folder
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
mkdir otelcol-contrib && tar xvzf otelcol-contrib_{{OTEL_VERSION}}_linux_amd64.tar.gz -C otelcol-contrib
|
||||
```
|
||||
|
||||
|
||||
### Step 3: Create config.yaml in folder otelcol-contrib with the below content in it
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
hostmetrics:
|
||||
collection_interval: 60s
|
||||
scrapers:
|
||||
cpu: {}
|
||||
disk: {}
|
||||
load: {}
|
||||
filesystem: {}
|
||||
memory: {}
|
||||
network: {}
|
||||
paging: {}
|
||||
process:
|
||||
mute_process_name_error: true
|
||||
mute_process_exe_error: true
|
||||
mute_process_io_error: true
|
||||
processes: {}
|
||||
prometheus:
|
||||
config:
|
||||
global:
|
||||
scrape_interval: 60s
|
||||
scrape_configs:
|
||||
- job_name: otel-collector-binary
|
||||
static_configs:
|
||||
- targets:
|
||||
# - localhost:8888
|
||||
processors:
|
||||
batch:
|
||||
send_batch_size: 1000
|
||||
timeout: 10s
|
||||
# Ref: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/resourcedetectionprocessor/README.md
|
||||
resourcedetection:
|
||||
detectors: [env, system] # Before system detector, include ec2 for AWS, gcp for GCP and azure for Azure.
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
timeout: 2s
|
||||
system:
|
||||
hostname_sources: [os] # alternatively, use [dns,os] for setting FQDN as host.name and os as fallback
|
||||
extensions:
|
||||
health_check: {}
|
||||
zpages: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: "ingest.{{REGION}}.signoz.cloud:443"
|
||||
tls:
|
||||
insecure: false
|
||||
headers:
|
||||
"signoz-access-token": "{{SIGNOZ_INGESTION_KEY}}"
|
||||
logging:
|
||||
verbosity: normal
|
||||
service:
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 0.0.0.0:8888
|
||||
extensions: [health_check, zpages]
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [otlp]
|
||||
metrics/internal:
|
||||
receivers: [prometheus, hostmetrics]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [otlp]
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [otlp]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [otlp]
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
|
||||
After setting up the Otel collector agent, follow the steps below to instrument your Elixir (Phoenix + Ecto) Application
|
||||
|
||||
### Step 1: Add dependencies
|
||||
Install dependencies related to OpenTelemetry by adding them to `mix.exs` file
|
||||
|
||||
```bash
|
||||
{:opentelemetry_exporter, "~> 1.6"},
|
||||
{:opentelemetry_api, "~> 1.2"},
|
||||
{:opentelemetry, "~> 1.3"},
|
||||
{:opentelemetry_semantic_conventions, "~> 0.2"},
|
||||
{:opentelemetry_cowboy, "~> 0.2.1"},
|
||||
{:opentelemetry_phoenix, "~> 1.1"},
|
||||
{:opentelemetry_ecto, "~> 1.1"}
|
||||
```
|
||||
|
||||
|
||||
In your application start, usually the `application.ex` file, setup the telemetry handlers
|
||||
|
||||
```bash
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:{{MYAPP}}, :repo])
|
||||
```
|
||||
|
||||
|
||||
As an example, this is how you can setup the handlers in your application.ex file for an application called demo :
|
||||
|
||||
```bash
|
||||
# application.ex
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:demo, :repo])
|
||||
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Step 2: Configure Application
|
||||
You need to configure your application to send telemetry data by adding the following config to your `runtime.exs` file:
|
||||
|
||||
```bash
|
||||
config :opentelemetry, :resource, service: %{name: "{{MYAPP}}"}
|
||||
|
||||
config :opentelemetry, :processors,
|
||||
otel_batch_processor: %{
|
||||
exporter:
|
||||
{:opentelemetry_exporter,
|
||||
%{endpoints: ["http://localhost:4318"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
OTel Collector binary helps to collect logs, hostmetrics, resource and infra attributes. It is recommended to install Otel Collector binary to collect and send traces to SigNoz cloud. You can correlate signals and have rich contextual data through this way.
|
||||
|
||||
You can find instructions to install OTel Collector binary [here](https://signoz.io/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/) in your VM. Once you are done setting up your OTel Collector binary, you can follow the below steps for instrumenting your Elixir (Phoenix + Ecto) application.
|
||||
|
||||
**Step 1. Add dependencies**
|
||||
|
||||
Install dependencies related to OpenTelemetry by adding them to `mix.exs` file
|
||||
|
||||
```bash
|
||||
{:opentelemetry_exporter, "~> 1.6"},
|
||||
{:opentelemetry_api, "~> 1.2"},
|
||||
{:opentelemetry, "~> 1.3"},
|
||||
{:opentelemetry_semantic_conventions, "~> 0.2"},
|
||||
{:opentelemetry_cowboy, "~> 0.2.1"},
|
||||
{:opentelemetry_phoenix, "~> 1.1"},
|
||||
{:opentelemetry_ecto, "~> 1.1"}
|
||||
```
|
||||
|
||||
In your application start, usually the `application.ex` file, setup the telemetry handlers
|
||||
|
||||
```elixir
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:YOUR_APP_NAME, :repo])
|
||||
```
|
||||
|
||||
As an example, this is how you can setup the handlers in your `application.ex` file for an application called `demo` :
|
||||
|
||||
```elixir
|
||||
# application.ex
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
:opentelemetry_cowboy.setup()
|
||||
OpentelemetryPhoenix.setup(adapter: :cowboy2)
|
||||
OpentelemetryEcto.setup([:demo, :repo])
|
||||
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2. Configure Application**
|
||||
|
||||
You need to configure your application to send telemtry data by adding the follwing config to your `runtime.exs` file:
|
||||
|
||||
```elixir
|
||||
config :opentelemetry, :resource, service: %{name: "{{MYAPP}}"}
|
||||
|
||||
config :opentelemetry, :processors,
|
||||
otel_batch_processor: %{
|
||||
exporter:
|
||||
{:opentelemetry_exporter,
|
||||
%{endpoints: ["http://localhost:4318"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,24 +1,43 @@
|
||||
## Install otel-collector in your Kubernetes infra
|
||||
|
||||
### Install otel-collector in your Kubernetes infra
|
||||
|
||||
Add the SigNoz Helm Chart repository
|
||||
```bash
|
||||
helm repo add signoz https://charts.signoz.io
|
||||
```
|
||||
|
||||
|
||||
|
||||
If the chart is already present, update the chart to the latest using:
|
||||
```bash
|
||||
helm repo update
|
||||
```
|
||||
|
||||
|
||||
|
||||
Install the Kubernetes Infrastructure chart provided by SigNoz
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra \
|
||||
--set otelCollectorEndpoint=ingest.{{REGION}}.signoz.cloud:443 \
|
||||
--set otelInsecure=false \
|
||||
--set signozApiKey={{SIGNOZ_INGESTION_KEY}} \
|
||||
--set global.clusterName=<CLUSTER_NAME>
|
||||
For generic Kubernetes clusters, you can create *override-values.yaml* with the following configuration:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
cloud: others
|
||||
clusterName: <CLUSTER_NAME>
|
||||
deploymentEnvironment: <DEPLOYMENT_ENVIRONMENT>
|
||||
otelCollectorEndpoint: ingest.{{REGION}}.signoz.cloud:443
|
||||
otelInsecure: false
|
||||
signozApiKey: {{SIGNOZ_INGESTION_KEY}}
|
||||
presets:
|
||||
otlpExporter:
|
||||
enabled: true
|
||||
loggingExporter:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
- Replace `<CLUSTER_NAME>` with the name of the Kubernetes cluster or a unique identifier of the cluster.
|
||||
- Replace `<DEPLOYMENT_ENVIRONMENT>` with the deployment environment of your application. Example: **"staging"**, **"production"**, etc.
|
||||
|
||||
|
||||
|
||||
To install the k8s-infra chart with the above configuration, run the following command:
|
||||
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra -f override-values.yaml
|
||||
```
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
1. **Install Dependencies**<br></br>
|
||||
Dependencies related to OpenTelemetry exporter and SDK have to be installed first. Note that we are assuming you are using `gin` request router. If you are using other request routers, check out the [corresponding package](#request-routers).
|
||||
|
||||
Run the below commands after navigating to the application source folder:
|
||||
|
||||
|
||||
```bash
|
||||
go get go.opentelemetry.io/otel \
|
||||
go.opentelemetry.io/otel/trace \
|
||||
go.opentelemetry.io/otel/sdk \
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
|
||||
```
|
||||
|
||||
|
||||
2. **Declare environment variables for configuring OpenTelemetry**<br></br>
|
||||
Declare the following global variables in `main.go` which we will use to configure OpenTelemetry:
|
||||
|
||||
```bash
|
||||
var (
|
||||
serviceName = "{{MYAPP}}")
|
||||
collectorURL = "https://ingest.{{REGION}}.signoz.cloud:443"
|
||||
headers="signoz-access-token={{SIGNOZ_INGESTION_KEY}}"
|
||||
insecure = os.Getenv("INSECURE_MODE")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
3. **Instrument your Go application with OpenTelemetry**<br></br>
|
||||
To configure your application to send data we will need a function to initialize OpenTelemetry. Add the following snippet of code in your `main.go` file.
|
||||
|
||||
```bash
|
||||
|
||||
import (
|
||||
.....
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func initTracer() func(context.Context) error {
|
||||
|
||||
var secureOption otlptracegrpc.Option
|
||||
|
||||
if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" {
|
||||
secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))
|
||||
} else {
|
||||
secureOption = otlptracegrpc.WithInsecure()
|
||||
}
|
||||
|
||||
exporter, err := otlptrace.New(
|
||||
context.Background(),
|
||||
otlptracegrpc.NewClient(
|
||||
secureOption,
|
||||
otlptracegrpc.WithEndpoint(collectorURL),
|
||||
otlptracegrpc.WithHeaders(headers),
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create exporter: %v", err)
|
||||
}
|
||||
resources, err := resource.New(
|
||||
context.Background(),
|
||||
resource.WithAttributes(
|
||||
attribute.String("{{MYAPP}}", serviceName),
|
||||
attribute.String("library.language", "go"),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not set resources: %v", err)
|
||||
}
|
||||
|
||||
otel.SetTracerProvider(
|
||||
sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithResource(resources),
|
||||
),
|
||||
)
|
||||
return exporter.Shutdown
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
4. **Initialize the tracer in main.go**<br></br>
|
||||
Modify the main function to initialise the tracer in `main.go`. Initiate the tracer at the very beginning of our main function.
|
||||
|
||||
```go
|
||||
func main() {
|
||||
cleanup := initTracer()
|
||||
defer cleanup(context.Background())
|
||||
|
||||
......
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
5. **Add the OpenTelemetry Gin middleware**<br></br>
|
||||
Configure Gin to use the middleware by adding the following lines in `main.go`.
|
||||
|
||||
```go
|
||||
import (
|
||||
....
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
......
|
||||
r := gin.Default()
|
||||
r.Use(otelgin.Middleware(serviceName))
|
||||
......
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
**Set environment variables and run your Go Gin application**<br></br>
|
||||
The run command must have some environment variables to send data to SigNoz cloud. The run commands:
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
setx INSECURE_MODE=false
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
## Setup OpenTelemetry Binary as an agent
|
||||
|
||||
|
||||
|
||||
As a first step, you should install the OTel collector Binary according to the instructions provided on [this link](https://signoz.io/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/).
|
||||
|
||||
|
||||
|
||||
Once you are done setting up the OTel collector binary, you can follow the next steps.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
1. **Install Dependencies**<br></br>
|
||||
Dependencies related to OpenTelemetry exporter and SDK have to be installed first. Note that we are assuming you are using `gin` request router. If you are using other request routers, check out the [corresponding package](#request-routers).
|
||||
|
||||
Run the below commands after navigating to the application source folder:
|
||||
|
||||
```bash
|
||||
go get go.opentelemetry.io/otel \
|
||||
go.opentelemetry.io/otel/trace \
|
||||
go.opentelemetry.io/otel/sdk \
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
|
||||
```
|
||||
|
||||
|
||||
2. **Declare environment variables for configuring OpenTelemetry**<br></br>
|
||||
Declare the following global variables in `main.go` which we will use to configure OpenTelemetry:
|
||||
|
||||
```go
|
||||
var (
|
||||
serviceName = os.Getenv("SERVICE_NAME")
|
||||
collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
insecure = os.Getenv("INSECURE_MODE")
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
3. **Instrument your Go application with OpenTelemetry**<br></br>
|
||||
To configure your application to send data we will need a function to initialize OpenTelemetry. Add the following snippet of code in your `main.go` file.
|
||||
|
||||
```go
|
||||
|
||||
import (
|
||||
.....
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func initTracer() func(context.Context) error {
|
||||
|
||||
var secureOption otlptracegrpc.Option
|
||||
|
||||
if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" {
|
||||
secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))
|
||||
} else {
|
||||
secureOption = otlptracegrpc.WithInsecure()
|
||||
}
|
||||
|
||||
exporter, err := otlptrace.New(
|
||||
context.Background(),
|
||||
otlptracegrpc.NewClient(
|
||||
secureOption,
|
||||
otlptracegrpc.WithEndpoint(collectorURL),
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create exporter: %v", err)
|
||||
}
|
||||
resources, err := resource.New(
|
||||
context.Background(),
|
||||
resource.WithAttributes(
|
||||
attribute.String("service.name", serviceName),
|
||||
attribute.String("library.language", "go"),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not set resources: %v", err)
|
||||
}
|
||||
|
||||
otel.SetTracerProvider(
|
||||
sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithResource(resources),
|
||||
),
|
||||
)
|
||||
return exporter.Shutdown
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
4. **Initialize the tracer in main.go**<br></br>
|
||||
Modify the main function to initialise the tracer in `main.go`. Initiate the tracer at the very beginning of our main function.
|
||||
|
||||
```go
|
||||
func main() {
|
||||
cleanup := initTracer()
|
||||
defer cleanup(context.Background())
|
||||
|
||||
......
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
5. **Add the OpenTelemetry Gin middleware**<br></br>
|
||||
Configure Gin to use the middleware by adding the following lines in `main.go`.
|
||||
|
||||
```go
|
||||
import (
|
||||
....
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
......
|
||||
r := gin.Default()
|
||||
r.Use(otelgin.Middleware(serviceName))
|
||||
......
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
**Set environment variables and run your Go Gin application**<br></br>
|
||||
The run command must have some environment variables to send data to SigNoz. Then run the following commands:
|
||||
|
||||
|
||||
```bash
|
||||
setx SERVICE_NAME={{MYAPP}}
|
||||
setx INSECURE_MODE=true
|
||||
setx OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
@@ -1,24 +1,43 @@
|
||||
## Install otel-collector in your Kubernetes infra
|
||||
|
||||
### Install otel-collector in your Kubernetes infra
|
||||
|
||||
Add the SigNoz Helm Chart repository
|
||||
```bash
|
||||
helm repo add signoz https://charts.signoz.io
|
||||
```
|
||||
|
||||
|
||||
|
||||
If the chart is already present, update the chart to the latest using:
|
||||
```bash
|
||||
helm repo update
|
||||
```
|
||||
|
||||
|
||||
|
||||
Install the Kubernetes Infrastructure chart provided by SigNoz
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra \
|
||||
--set otelCollectorEndpoint=ingest.{{REGION}}.signoz.cloud:443 \
|
||||
--set otelInsecure=false \
|
||||
--set signozApiKey={{SIGNOZ_INGESTION_KEY}} \
|
||||
--set global.clusterName=<CLUSTER_NAME>
|
||||
For generic Kubernetes clusters, you can create *override-values.yaml* with the following configuration:
|
||||
|
||||
```yaml
|
||||
global:
|
||||
cloud: others
|
||||
clusterName: <CLUSTER_NAME>
|
||||
deploymentEnvironment: <DEPLOYMENT_ENVIRONMENT>
|
||||
otelCollectorEndpoint: ingest.{{REGION}}.signoz.cloud:443
|
||||
otelInsecure: false
|
||||
signozApiKey: {{SIGNOZ_INGESTION_KEY}}
|
||||
presets:
|
||||
otlpExporter:
|
||||
enabled: true
|
||||
loggingExporter:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
- Replace `<CLUSTER_NAME>` with the name of the Kubernetes cluster or a unique identifier of the cluster.
|
||||
- Replace `<DEPLOYMENT_ENVIRONMENT>` with the deployment environment of your application. Example: **"staging"**, **"production"**, etc.
|
||||
|
||||
|
||||
|
||||
To install the k8s-infra chart with the above configuration, run the following command:
|
||||
|
||||
```bash
|
||||
helm install my-release signoz/k8s-infra -f override-values.yaml
|
||||
```
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
**Step 1.** Download otel java binary agent
|
||||
|
||||
```bash
|
||||
wget https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar
|
||||
```
|
||||
|
||||
|
||||
**Step 2.** Edit your configuration file,i.e `standalone.conf` for JBoss with nano or notepad.
|
||||
|
||||
|
||||
|
||||
**Step 3.** Update `JAVA_OPTS` environment variable
|
||||
|
||||
Update `JAVA_OPTS` environment variable with configurations required to send data to SigNoz cloud in your configuration file.
|
||||
|
||||
```bash
|
||||
set JAVA_OPTS=-javaagent:C:\path\to\opentelemetry-javaagent.jar
|
||||
set JAVA_OPTS=%JAVA_OPTS% -Dotel.exporter.otlp.endpoint=https://ingest.{{REGION}}.signoz.cloud:443
|
||||
set JAVA_OPTS=%JAVA_OPTS% -Dotel.exporter.otlp.headers="signoz-access-token={{SIGNOZ_INGESTION_KEY}}"
|
||||
set JAVA_OPTS=%JAVA_OPTS% -Dotel.resource.attributes="service.name={{MYAPP}}"
|
||||
```
|
||||
|
||||
|
||||
You need to replace the following things based on your environment:<br></br>
|
||||
|
||||
- `path` - Update it to the path of your downloaded Java JAR agent instead of `C:\path\to\opentelemetry-javaagent.jar`<br></br>
|
||||
@@ -0,0 +1,7 @@
|
||||
Write the output/logs of standalone.sh script to a file nohup.out as a background thread
|
||||
|
||||
```bash
|
||||
/opt/jboss-eap-7.1/bin/standalone.sh > /opt/jboss-eap-7.1/bin/nohup.out &
|
||||
```
|
||||
|
||||
In case you encounter an issue where all applications do not get listed in the services section then please refer to the [troubleshooting section](#troubleshooting-your-installation).
|
||||
@@ -0,0 +1,12 @@
|
||||
## Setup OpenTelemetry Binary as an agent
|
||||
|
||||
|
||||
|
||||
As a first step, you should install the OTel collector Binary according to the instructions provided on [this link](https://signoz.io/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/).
|
||||
|
||||
|
||||
|
||||
Once you are done setting up the OTel collector binary, you can follow the next steps.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
After setting up the Otel collector agent, follow the steps below to instrument your JavaScript Application
|
||||
|
||||
#### Requirements
|
||||
- Java 8 or higher
|
||||
|
||||
|
||||
|
||||
Open the configuration file, generally at `jboss-eap-7.1/bin/standalone.conf` .
|
||||
@@ -0,0 +1,13 @@
|
||||
Update `JAVA_OPTS` environment variable
|
||||
|
||||
Update `JAVA_OPTS` environment variable with configurations required to send data to SigNoz cloud in your configuration file.
|
||||
|
||||
```bash
|
||||
JAVA_OPTS="-javaagent:C:/path/to/opentelemetry-javaagent.jar"
|
||||
```
|
||||
|
||||
|
||||
where,
|
||||
- `path` - Update it to the path of your downloaded Java JAR agent.<br></br>
|
||||
|
||||
In case you encounter an issue where all applications do not get listed in the services section then please refer to the [troubleshooting section](#troubleshooting-your-installation).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user