Compare commits
77 Commits
v0.51.0-cl
...
v0.52.0-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e96e544f | ||
|
|
871e5ada9e | ||
|
|
0401c27dbc | ||
|
|
57c45f22d6 | ||
|
|
29f1883edd | ||
|
|
5d903b5487 | ||
|
|
1b9683d699 | ||
|
|
65280cf4e1 | ||
|
|
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 | ||
|
|
220edd139a | ||
|
|
59121bd932 | ||
|
|
aef935a817 | ||
|
|
f300518d61 | ||
|
|
18b608a1d8 | ||
|
|
738d62c9cf | ||
|
|
38e694cd36 | ||
|
|
1281330c52 | ||
|
|
7b7cca7db7 | ||
|
|
3134e8c1cf | ||
|
|
d00024b64a | ||
|
|
4360cd0397 | ||
|
|
a688b6c60e | ||
|
|
522e73b48e | ||
|
|
ba7e6fcf23 | ||
|
|
eefccafa5b | ||
|
|
05bd6d52f1 | ||
|
|
d60daef171 | ||
|
|
d50530f58c | ||
|
|
6957bd71ca | ||
|
|
ef8b50c19e |
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ receivers:
|
||||
# endpoint: 0.0.0.0:6832
|
||||
hostmetrics:
|
||||
collection_interval: 30s
|
||||
root_path: /hostfs
|
||||
scrapers:
|
||||
cpu: {}
|
||||
load: {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +1,15 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
_ "net/http/pprof" // http profiler
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -28,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"
|
||||
@@ -41,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"
|
||||
@@ -110,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 {
|
||||
@@ -118,33 +126,13 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
|
||||
localDB.SetMaxOpenConns(10)
|
||||
|
||||
gatewayFeature := basemodel.Feature{
|
||||
Name: "GATEWAY",
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
}
|
||||
|
||||
//Activate this feature if the url is not empty
|
||||
var gatewayProxy *httputil.ReverseProxy
|
||||
if serverOptions.GatewayUrl == "" {
|
||||
gatewayFeature.Active = false
|
||||
gatewayProxy, err = gateway.NewNoopProxy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
zap.L().Info("Enabling gateway feature flag ...")
|
||||
gatewayFeature.Active = true
|
||||
gatewayProxy, err = gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// initiate license manager
|
||||
lm, err := licensepkg.StartManager("sqlite", localDB, gatewayFeature)
|
||||
lm, err := licensepkg.StartManager("sqlite", localDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,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 {
|
||||
@@ -323,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)
|
||||
@@ -340,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)
|
||||
|
||||
@@ -358,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)
|
||||
@@ -414,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"
|
||||
@@ -732,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)
|
||||
|
||||
@@ -20,11 +20,14 @@ import (
|
||||
func (m *modelDao) createUserForSAMLRequest(ctx context.Context, email string) (*basemodel.User, basemodel.BaseApiError) {
|
||||
// get auth domain from email domain
|
||||
domain, apierr := m.GetDomainByEmail(ctx, email)
|
||||
|
||||
if apierr != nil {
|
||||
zap.L().Error("failed to get domain from email", zap.Error(apierr))
|
||||
return nil, model.InternalErrorStr("failed to get domain from email")
|
||||
}
|
||||
if domain == nil {
|
||||
zap.L().Error("email domain does not match any authenticated domain", zap.String("email", email))
|
||||
return nil, model.InternalErrorStr("email domain does not match any authenticated domain")
|
||||
}
|
||||
|
||||
hash, err := baseauth.PasswordHash(utils.GeneratePassowrd())
|
||||
if err != nil {
|
||||
|
||||
@@ -5,5 +5,5 @@ import (
|
||||
)
|
||||
|
||||
func NewNoopProxy() (*httputil.ReverseProxy, error) {
|
||||
return nil, nil
|
||||
return &httputil.ReverseProxy{}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const Enterprise = "ENTERPRISE_PLAN"
|
||||
const DisableUpsell = "DISABLE_UPSELL"
|
||||
const Onboarding = "ONBOARDING"
|
||||
const ChatSupport = "CHAT_SUPPORT"
|
||||
const Gateway = "GATEWAY"
|
||||
const PremiumSupport = "PREMIUM_SUPPORT"
|
||||
|
||||
var BasicPlan = basemodel.FeatureSet{
|
||||
basemodel.Feature{
|
||||
@@ -111,6 +113,20 @@ var BasicPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: Gateway,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: PremiumSupport,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
var ProPlan = basemodel.FeatureSet{
|
||||
@@ -205,6 +221,20 @@ var ProPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: Gateway,
|
||||
Active: true,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: PremiumSupport,
|
||||
Active: true,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
var EnterprisePlan = basemodel.FeatureSet{
|
||||
@@ -313,4 +343,18 @@ var EnterprisePlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: Gateway,
|
||||
Active: true,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: PremiumSupport,
|
||||
Active: true,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
1
frontend/public/Icons/solid-x-circle.svg
Normal file
1
frontend/public/Icons/solid-x-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -76,9 +76,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
isUserFetching: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoggedIn) {
|
||||
history.push(ROUTES.LOGIN);
|
||||
history.push(ROUTES.LOGIN, { from: pathname });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -48,7 +49,7 @@ function App(): JSX.Element {
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
|
||||
const { trackPageView, trackEvent } = useAnalytics();
|
||||
const { trackPageView } = useAnalytics();
|
||||
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
@@ -65,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: {
|
||||
@@ -81,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', {
|
||||
@@ -199,7 +208,7 @@ function App(): JSX.Element {
|
||||
LOCALSTORAGE.THEME_ANALYTICS_V1,
|
||||
);
|
||||
if (!isThemeAnalyticsSent) {
|
||||
trackEvent('Theme Analytics', {
|
||||
logEvent('Theme Analytics', {
|
||||
theme: isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT,
|
||||
user: pick(user, ['email', 'userId', 'name']),
|
||||
org,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
frontend/src/api/common/getQueryStats.ts
Normal file
32
frontend/src/api/common/getQueryStats.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
export interface WsDataEvent {
|
||||
read_rows: number;
|
||||
read_bytes: number;
|
||||
elapsed_ms: number;
|
||||
}
|
||||
interface GetQueryStatsProps {
|
||||
queryId: string;
|
||||
setData: React.Dispatch<React.SetStateAction<WsDataEvent | undefined>>;
|
||||
}
|
||||
|
||||
export function getQueryStats(props: GetQueryStatsProps): void {
|
||||
const { queryId, setData } = props;
|
||||
|
||||
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
const socket = new WebSocket(
|
||||
`${ENVIRONMENT.wsURL}/api/v3/query_progress?q=${queryId}`,
|
||||
token,
|
||||
);
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(event?.data);
|
||||
setData(parsedData);
|
||||
} catch {
|
||||
setData(event?.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -12,10 +12,13 @@ export const getMetricsQueryRange = async (
|
||||
props: QueryRangePayload,
|
||||
version: string,
|
||||
signal: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
|
||||
try {
|
||||
if (version && version === ENTITY_VERSION_V4) {
|
||||
const response = await ApiV4Instance.post('/query_range', props, { signal });
|
||||
const response = await ApiV4Instance.post('/query_range', props, {
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
@@ -26,7 +29,10 @@ export const getMetricsQueryRange = async (
|
||||
};
|
||||
}
|
||||
|
||||
const response = await ApiV3Instance.post('/query_range', props, { signal });
|
||||
const response = await ApiV3Instance.post('/query_range', props, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
63
frontend/src/api/queryBuilder/getAttributeSuggestions.ts
Normal file
63
frontend/src/api/queryBuilder/getAttributeSuggestions.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiV3Instance } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
|
||||
import { encode } from 'js-base64';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
IGetAttributeSuggestionsPayload,
|
||||
IGetAttributeSuggestionsSuccessResponse,
|
||||
} from 'types/api/queryBuilder/getAttributeSuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export const getAttributeSuggestions = async ({
|
||||
searchText,
|
||||
dataSource,
|
||||
filters,
|
||||
}: IGetAttributeSuggestionsPayload): Promise<
|
||||
SuccessResponse<IGetAttributeSuggestionsSuccessResponse> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
let base64EncodedFiltersString;
|
||||
try {
|
||||
// the replace function is to remove the padding at the end of base64 encoded string which is auto added to make it a multiple of 4
|
||||
// why ? because the current working of qs doesn't work well with padding
|
||||
base64EncodedFiltersString = encode(JSON.stringify(filters)).replace(
|
||||
/=+$/,
|
||||
'',
|
||||
);
|
||||
} catch {
|
||||
// default base64 encoded string for empty filters object
|
||||
base64EncodedFiltersString = 'eyJpdGVtcyI6W10sIm9wIjoiQU5EIn0';
|
||||
}
|
||||
const response: AxiosResponse<{
|
||||
data: IGetAttributeSuggestionsSuccessResponse;
|
||||
}> = await ApiV3Instance.get(
|
||||
`/filter_suggestions?${createQueryParams({
|
||||
searchText,
|
||||
dataSource,
|
||||
existingFilter: base64EncodedFiltersString,
|
||||
})}`,
|
||||
);
|
||||
|
||||
const payload: BaseAutocompleteData[] =
|
||||
response.data.data.attributes?.map(({ id: _, ...item }) => ({
|
||||
...item,
|
||||
id: createIdFromObjectFields(item, baseAutoCompleteIdKeysOrder),
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.statusText,
|
||||
payload: {
|
||||
attributes: payload,
|
||||
example_queries: response.data.data.example_queries,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return ErrorResponseHandler(e as AxiosError);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ function ValueGraph({
|
||||
}
|
||||
>
|
||||
<Tooltip title={t('this_value_satisfies_multiple_thresholds')}>
|
||||
<ExclamationCircleFilled className="value-graph-icon" />
|
||||
<ExclamationCircleFilled
|
||||
className="value-graph-icon"
|
||||
data-testid="conflicting-thresholds"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import './FacingIssueBtn.style.scss';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import useFeatureFlags from 'hooks/useFeatureFlag';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { isCloudUser } from 'utils/app';
|
||||
|
||||
export interface FacingIssueBtnProps {
|
||||
eventName: string;
|
||||
attributes: Record<string, unknown>;
|
||||
message?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
onHoverText?: string;
|
||||
}
|
||||
|
||||
function FacingIssueBtn({
|
||||
attributes,
|
||||
eventName,
|
||||
message = '',
|
||||
buttonText = '',
|
||||
className = '',
|
||||
onHoverText = '',
|
||||
}: FacingIssueBtnProps): JSX.Element | null {
|
||||
const handleFacingIssuesClick = (): void => {
|
||||
logEvent(eventName, attributes);
|
||||
|
||||
if (window.Intercom) {
|
||||
window.Intercom('showNewMessage', defaultTo(message, ''));
|
||||
}
|
||||
};
|
||||
|
||||
const isChatSupportEnabled = useFeatureFlags(FeatureKeys.CHAT_SUPPORT)?.active;
|
||||
const isCloudUserVal = isCloudUser();
|
||||
|
||||
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
|
||||
<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: '',
|
||||
};
|
||||
|
||||
export default FacingIssueBtn;
|
||||
@@ -3,4 +3,5 @@ export const ENVIRONMENT = {
|
||||
process?.env?.FRONTEND_API_ENDPOINT ||
|
||||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
|
||||
'',
|
||||
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const selectValueDivider = '__';
|
||||
|
||||
export const baseAutoCompleteIdKeysOrder: (keyof Omit<
|
||||
BaseAutocompleteData,
|
||||
'id' | 'isJSON'
|
||||
'id' | 'isJSON' | 'isIndexed'
|
||||
>)[] = ['key', 'dataType', 'type', 'isColumn'];
|
||||
|
||||
export const autocompleteType: Record<AutocompleteType, AutocompleteType> = {
|
||||
@@ -71,6 +71,7 @@ export const alphabet: string[] = alpha.map((str) => String.fromCharCode(str));
|
||||
export enum QueryBuilderKeys {
|
||||
GET_AGGREGATE_ATTRIBUTE = 'GET_AGGREGATE_ATTRIBUTE',
|
||||
GET_AGGREGATE_KEYS = 'GET_AGGREGATE_KEYS',
|
||||
GET_ATTRIBUTE_SUGGESTIONS = 'GET_ATTRIBUTE_SUGGESTIONS',
|
||||
}
|
||||
|
||||
export const mapOfOperators = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ const userOS = getUserOperatingSystem();
|
||||
export const LogsExplorerShortcuts = {
|
||||
StageAndRunQuery: 'enter+meta',
|
||||
FocusTheSearchBar: 's',
|
||||
ShowAllFilters: '/+meta',
|
||||
};
|
||||
|
||||
export const LogsExplorerShortcutsName = {
|
||||
@@ -11,9 +12,11 @@ export const LogsExplorerShortcutsName = {
|
||||
userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl'
|
||||
}+enter`,
|
||||
FocusTheSearchBar: 's',
|
||||
ShowAllFilters: `${userOS === UserOperatingSystem.MACOS ? 'cmd' : 'ctrl'}+/`,
|
||||
};
|
||||
|
||||
export const LogsExplorerShortcutsDescription = {
|
||||
StageAndRunQuery: 'Stage and Run the current query',
|
||||
FocusTheSearchBar: 'Shift the focus to the last query filter bar',
|
||||
ShowAllFilters: 'Toggle all filters in the filters dropdown',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ import { ColumnsType } from 'antd/es/table';
|
||||
import updateCreditCardApi from 'api/billing/checkout';
|
||||
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import manageCreditCardApi from 'api/billing/manage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useAnalytics from 'hooks/analytics/useAnalytics';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
import useLicense from 'hooks/useLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -137,8 +137,6 @@ export default function BillingContainer(): JSX.Element {
|
||||
Partial<UsageResponsePayloadProps>
|
||||
>({});
|
||||
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const { isFetching, data: licensesData, error: licenseError } = useLicense();
|
||||
|
||||
const { user, org } = useSelector<AppState, AppReducer>((state) => state.app);
|
||||
@@ -316,7 +314,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
|
||||
const handleBilling = useCallback(async () => {
|
||||
if (isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription) {
|
||||
trackEvent('Billing : Upgrade Plan', {
|
||||
logEvent('Billing : Upgrade Plan', {
|
||||
user: pick(user, ['email', 'userId', 'name']),
|
||||
org,
|
||||
});
|
||||
@@ -327,7 +325,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
cancelURL: window.location.href,
|
||||
});
|
||||
} else {
|
||||
trackEvent('Billing : Manage Billing', {
|
||||
logEvent('Billing : Manage Billing', {
|
||||
user: pick(user, ['email', 'userId', 'name']),
|
||||
org,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
Check,
|
||||
ConciergeBell,
|
||||
@@ -56,7 +57,7 @@ import { useHistory } from 'react-router-dom';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -120,6 +121,21 @@ function ExplorerOptions({
|
||||
|
||||
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
|
||||
|
||||
const handleConditionalQueryModification = useCallback((): string => {
|
||||
if (
|
||||
query?.builder?.queryData?.[0]?.aggregateOperator !== StringOperators.NOOP
|
||||
) {
|
||||
return JSON.stringify(query);
|
||||
}
|
||||
|
||||
// Modify aggregateOperator to count, as noop is not supported in alerts
|
||||
const modifiedQuery = cloneDeep(query);
|
||||
|
||||
modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT;
|
||||
|
||||
return JSON.stringify(modifiedQuery);
|
||||
}, [query]);
|
||||
|
||||
const onCreateAlertsHandler = useCallback(() => {
|
||||
if (sourcepage === DataSource.TRACES) {
|
||||
logEvent('Traces Explorer: Create alert', {
|
||||
@@ -130,13 +146,16 @@ function ExplorerOptions({
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
|
||||
const stringifiedQuery = handleConditionalQueryModification();
|
||||
|
||||
history.push(
|
||||
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
JSON.stringify(query),
|
||||
stringifiedQuery,
|
||||
)}`,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [history, query]);
|
||||
}, [handleConditionalQueryModification, history]);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
|
||||
@@ -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,14 +664,15 @@ function DashboardsList(): JSX.Element {
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage dashboards for your workspace.
|
||||
</Typography.Text>
|
||||
<FacingIssueBtn
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
screen: 'Dashboard list page',
|
||||
}}
|
||||
eventName="Dashboard: Facing Issues in dashboard"
|
||||
message={dashboardListMessage}
|
||||
buttonText="Facing issues with dashboards?"
|
||||
buttonText="Need help with dashboards?"
|
||||
onHoverText="Click here to get help with dashboards"
|
||||
intercomMessageDisabled
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -80,6 +80,36 @@
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.rows {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #242834;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-actions-container {
|
||||
@@ -149,6 +179,15 @@
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
.query-stats {
|
||||
.rows {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
42
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
42
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Spin } from 'antd';
|
||||
import { CircleCheck } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return <Spin spinning size="small" indicator={<LoadingOutlined spin />} />;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src="/Icons/solid-x-circle.svg"
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './LogsExplorerViews.styles.scss';
|
||||
|
||||
import { Button } from 'antd';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -48,7 +50,15 @@ import {
|
||||
} from 'lodash-es';
|
||||
import { Sliders } from 'lucide-react';
|
||||
import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -69,12 +79,20 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsExplorerViews({
|
||||
selectedView,
|
||||
showFrequencyChart,
|
||||
setIsLoadingQueries,
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
}: {
|
||||
selectedView: SELECTED_VIEWS;
|
||||
showFrequencyChart: boolean;
|
||||
setIsLoadingQueries: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
listQueryKeyRef: MutableRefObject<any>;
|
||||
chartQueryKeyRef: MutableRefObject<any>;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const history = useHistory();
|
||||
@@ -116,6 +134,8 @@ function LogsExplorerViews({
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [queryStats, setQueryStats] = useState<WsDataEvent>();
|
||||
|
||||
const handleAxisError = useAxiosError();
|
||||
|
||||
@@ -214,9 +234,18 @@ function LogsExplorerViews({
|
||||
{
|
||||
enabled: !!listChartQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
chartQueryKeyRef,
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useGetExplorerQueryRange(
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useGetExplorerQueryRange(
|
||||
requestData,
|
||||
panelType,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
@@ -232,6 +261,11 @@ function LogsExplorerViews({
|
||||
end: timeRange.end,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
listQueryKeyRef,
|
||||
{
|
||||
...(!isEmpty(queryId) && { 'X-SIGNOZ-QUERY-ID': queryId }),
|
||||
},
|
||||
);
|
||||
|
||||
const getRequestData = useCallback(
|
||||
@@ -318,6 +352,23 @@ function LogsExplorerViews({
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryId(v4());
|
||||
}, [isError, isSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEmpty(queryId) &&
|
||||
(isLoading || isFetching) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST
|
||||
) {
|
||||
setQueryStats(undefined);
|
||||
setTimeout(() => {
|
||||
getQueryStats({ queryId, setData: setQueryStats });
|
||||
}, 500);
|
||||
}
|
||||
}, [queryId, isLoading, isFetching, selectedPanelType]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
@@ -569,6 +620,25 @@ function LogsExplorerViews({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isLoading ||
|
||||
isFetching ||
|
||||
isLoadingListChartData ||
|
||||
isFetchingListChartData
|
||||
) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingListChartData,
|
||||
isLoadingListChartData,
|
||||
setIsLoadingQueries,
|
||||
]);
|
||||
|
||||
const flattenLogData = useMemo(
|
||||
() =>
|
||||
logs.map((log) => {
|
||||
@@ -665,6 +735,30 @@ function LogsExplorerViews({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
{queryStats?.read_rows && (
|
||||
<Typography.Text className="rows">
|
||||
{getYAxisFormattedValue(queryStats.read_rows?.toString(), 'short')}{' '}
|
||||
rows
|
||||
</Typography.Text>
|
||||
)}
|
||||
{queryStats?.elapsed_ms && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<Typography.Text className="time">
|
||||
{getYAxisFormattedValue(queryStats?.elapsed_ms?.toString(), 'ms')}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('api/common/getQueryStats', () => jest.fn());
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
@@ -79,6 +81,9 @@ const renderer = (): RenderResult =>
|
||||
<LogsExplorerViews
|
||||
selectedView={SELECTED_VIEWS.SEARCH}
|
||||
showFrequencyChart
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</QueryBuilderProvider>
|
||||
|
||||
@@ -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,44 +294,46 @@ 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>
|
||||
<FacingIssueBtn
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: updatedTitle,
|
||||
screen: 'Dashboard Details',
|
||||
}}
|
||||
eventName="Dashboard: Facing Issues in dashboard"
|
||||
message={dashboardHelpMessage(selectedDashboard?.data, selectedDashboard)}
|
||||
buttonText="Facing issues with dashboards?"
|
||||
onHoverText="Click here to get help with dashboard details"
|
||||
/>
|
||||
</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">
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: updatedTitle,
|
||||
screen: 'Dashboard Details',
|
||||
}}
|
||||
eventName="Dashboard: Facing Issues in dashboard"
|
||||
message={dashboardHelpMessage(selectedDashboard?.data, selectedDashboard)}
|
||||
buttonText="Need help with this dashboard?"
|
||||
onHoverText="Click here to get help with dashboard"
|
||||
intercomMessageDisabled
|
||||
/>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
@@ -333,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,6 +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 LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
@@ -236,6 +237,21 @@ function QuerySection({
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: selectedDashboard?.data.title,
|
||||
screen: 'Dashboard widget',
|
||||
panelType: selectedGraph,
|
||||
widgetId: query.id,
|
||||
queryType: currentQuery.queryType,
|
||||
}}
|
||||
eventName="Dashboard: Facing Issues in dashboard"
|
||||
buttonText="Need help with this chart?"
|
||||
// message={chartHelpMessage(selectedDashboard, graphType)}
|
||||
onHoverText="Click here to get help with this dashboard widget"
|
||||
intercomMessageDisabled
|
||||
/>
|
||||
<TextToolTip
|
||||
text="This will temporarily save the current query and graph state. This will persist across tab change"
|
||||
url="https://signoz.io/docs/userguide/query-builder?utm_source=product&utm_medium=query-builder"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,8 +4,6 @@ import './NewWidget.styles.scss';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn';
|
||||
import { chartHelpMessage } from 'components/facingIssueBtn/util';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -79,6 +77,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
stagedQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
supersetQuery,
|
||||
setSupersetQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isQueryModified = useMemo(
|
||||
@@ -548,6 +547,17 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
isNewTraceLogsAvailable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* we need this extra handling for superset query because we cannot keep this in sync with current query
|
||||
* always.we do not sync superset query in the initQueryBuilderData because that function is called on all stage and run
|
||||
* actions. we do not want that as we loose out on superset functionalities if we do the same. hence initialising the superset query
|
||||
* on mount here with the currentQuery in the begining itself
|
||||
*/
|
||||
setSupersetQuery(currentQuery);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerShortcut(DashboardShortcuts.SaveChanges, onSaveDashboard);
|
||||
registerShortcut(DashboardShortcuts.DiscardChanges, onClickDiscardHandler);
|
||||
@@ -596,20 +606,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
<Typography.Text className="configure-panel">
|
||||
Configure panel
|
||||
</Typography.Text>
|
||||
<FacingIssueBtn
|
||||
attributes={{
|
||||
uuid: selectedDashboard?.uuid,
|
||||
title: selectedDashboard?.data.title,
|
||||
screen: 'Dashboard widget',
|
||||
panelType: graphType,
|
||||
widgetId: query.get('widgetId'),
|
||||
queryType: currentQuery.queryType,
|
||||
}}
|
||||
eventName="Dashboard: Facing Issues in dashboard"
|
||||
message={chartHelpMessage(selectedDashboard, graphType)}
|
||||
buttonText="Facing issues with dashboards?"
|
||||
onHoverText="Click here to get help with dashboard widget"
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
{isSaveDisabled && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user