Compare commits
36 Commits
playwright
...
fix/traces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce2ef92fe | ||
|
|
0ca264237e | ||
|
|
debf130a1d | ||
|
|
645d0f2f6d | ||
|
|
1e041590d5 | ||
|
|
8781600a48 | ||
|
|
879796cb52 | ||
|
|
55249d68fc | ||
|
|
d809b351b9 | ||
|
|
5b0d90fcce | ||
|
|
be82703820 | ||
|
|
456b505b60 | ||
|
|
9fe9c7a6ff | ||
|
|
2bdefc1051 | ||
|
|
c632fb5ef0 | ||
|
|
bd42995de6 | ||
|
|
d0a8cc4de3 | ||
|
|
dd62215246 | ||
|
|
11a4f6ae96 | ||
|
|
bfaa9df636 | ||
|
|
50e83be4af | ||
|
|
5609398f53 | ||
|
|
f70e71070a | ||
|
|
ff8e2ab6d0 | ||
|
|
8121e61409 | ||
|
|
416e8d2a5e | ||
|
|
43a6c7dcd6 | ||
|
|
5005cae2ad | ||
|
|
3123447005 | ||
|
|
6c59b5405e | ||
|
|
d26b57b0d8 | ||
|
|
da17375f10 | ||
|
|
a96489d06e | ||
|
|
8c29debb52 | ||
|
|
9cebd49a2c | ||
|
|
a22ef64bb0 |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
- bootstrap
|
||||
- auth
|
||||
- querier
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.96.1
|
||||
image: signoz/signoz:v0.97.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -233,7 +233,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.96.1
|
||||
image: signoz/signoz:v0.97.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.6
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.6
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
image: signoz/signoz:${VERSION:-v0.97.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.96.1}
|
||||
image: signoz/signoz:${VERSION:-v0.97.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -166,7 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -232,7 +232,7 @@ func (p *BaseSeasonalProvider) getPredictedSeries(
|
||||
// moving avg of the previous period series + z score threshold * std dev of the series
|
||||
// moving avg of the previous period series - z score threshold * std dev of the series
|
||||
func (p *BaseSeasonalProvider) getBounds(
|
||||
series, predictedSeries *qbtypes.TimeSeries,
|
||||
series, predictedSeries, weekSeries *qbtypes.TimeSeries,
|
||||
zScoreThreshold float64,
|
||||
) (*qbtypes.TimeSeries, *qbtypes.TimeSeries) {
|
||||
upperBoundSeries := &qbtypes.TimeSeries{
|
||||
@@ -246,8 +246,8 @@ func (p *BaseSeasonalProvider) getBounds(
|
||||
}
|
||||
|
||||
for idx, curr := range series.Values {
|
||||
upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(series)
|
||||
lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(series)
|
||||
upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(weekSeries)
|
||||
lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(weekSeries)
|
||||
upperBoundSeries.Values = append(upperBoundSeries.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: curr.Timestamp,
|
||||
Value: upperBound,
|
||||
@@ -398,8 +398,6 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
|
||||
aggOfInterest := result.Aggregations[0]
|
||||
|
||||
for _, series := range aggOfInterest.Series {
|
||||
stdDev := p.getStdDev(series)
|
||||
p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels)
|
||||
|
||||
pastPeriodSeries := p.getMatchingSeries(ctx, pastPeriodResult, series)
|
||||
currentSeasonSeries := p.getMatchingSeries(ctx, currentSeasonResult, series)
|
||||
@@ -407,6 +405,9 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
|
||||
past2SeasonSeries := p.getMatchingSeries(ctx, past2SeasonResult, series)
|
||||
past3SeasonSeries := p.getMatchingSeries(ctx, past3SeasonResult, series)
|
||||
|
||||
stdDev := p.getStdDev(currentSeasonSeries)
|
||||
p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels)
|
||||
|
||||
prevSeriesAvg := p.getAvg(pastPeriodSeries)
|
||||
currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries)
|
||||
pastSeasonSeriesAvg := p.getAvg(pastSeasonSeries)
|
||||
@@ -435,6 +436,7 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
|
||||
upperBoundSeries, lowerBoundSeries := p.getBounds(
|
||||
series,
|
||||
predictedSeries,
|
||||
currentSeasonSeries,
|
||||
zScoreThreshold,
|
||||
)
|
||||
aggOfInterest.UpperBoundSeries = append(aggOfInterest.UpperBoundSeries, upperBoundSeries)
|
||||
|
||||
79
ee/authz/openfgaauthz/provider.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package openfgaauthz
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
pkgopenfgaauthz "github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
pkgAuthzService authz.AuthZ
|
||||
}
|
||||
|
||||
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
|
||||
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema)
|
||||
})
|
||||
}
|
||||
|
||||
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (authz.AuthZ, error) {
|
||||
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema)
|
||||
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &provider{
|
||||
pkgAuthzService: pkgAuthzService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
return provider.pkgAuthzService.Start(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
return provider.pkgAuthzService.Stop(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
|
||||
return provider.pkgAuthzService.Check(ctx, tuple)
|
||||
}
|
||||
|
||||
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.BatchCheck(ctx, tuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
|
||||
return provider.pkgAuthzService.BatchCheck(ctx, tuples)
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
return provider.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
return provider.pkgAuthzService.Write(ctx, additions, deletions)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
authzDeniedMessage string = "::AUTHZ-DENIED::"
|
||||
)
|
||||
|
||||
type AuthZ struct {
|
||||
logger *slog.Logger
|
||||
authzService authz.AuthZ
|
||||
}
|
||||
|
||||
func NewAuthZ(logger *slog.Logger) *AuthZ {
|
||||
if logger == nil {
|
||||
panic("cannot build authz middleware, logger is empty")
|
||||
}
|
||||
|
||||
return &AuthZ{logger: logger}
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := claims.IsViewer(); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := claims.IsEditor(); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := claims.IsAdmin(); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := mux.Vars(req)["id"]
|
||||
if err := claims.IsSelfAccess(id); err != nil {
|
||||
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
selector, err := cb(req.Context(), claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
@@ -78,11 +78,6 @@ func NewAnomalyRule(
|
||||
|
||||
opts = append(opts, baserules.WithLogger(logger))
|
||||
|
||||
if p.RuleCondition.CompareOp == ruletypes.ValueIsBelow {
|
||||
target := -1 * *p.RuleCondition.Target
|
||||
p.RuleCondition.Target = &target
|
||||
}
|
||||
|
||||
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
16
frontend/public/Logos/anthropic-api-monitoring.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<metadata>
|
||||
<sfw xmlns="ns_sfw;">
|
||||
<slices>
|
||||
</slices>
|
||||
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
|
||||
</sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
|
||||
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 714 B |
1
frontend/public/Logos/claude-code.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/Logos/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
frontend/public/Logos/google-gemini.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
frontend/public/Logos/langchain.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LangChain</title><path d="M8.373 14.502c.013-.06.024-.118.038-.17l.061.145c.115.28.229.557.506.714-.012.254-.334.357-.552.326-.048-.114-.115-.228-.255-.164-.143.056-.3-.01-.266-.185.333-.012.407-.371.468-.666zM18.385 9.245c-.318 0-.616.122-.839.342l-.902.887c-.243.24-.368.572-.343.913l.006.056c.032.262.149.498.337.682.13.128.273.21.447.266a.866.866 0 01-.247.777l-.056.055a2.022 2.022 0 01-1.355-1.555l-.01-.057-.046.037c-.03.024-.06.05-.088.078l-.902.887a1.156 1.156 0 000 1.65c.231.228.535.342.84.342.304 0 .607-.114.838-.341l.902-.888a1.156 1.156 0 00-.436-1.921.953.953 0 01.276-.842 2.062 2.062 0 011.371 1.57l.01.057.047-.037c.03-.024.06-.05.088-.078l.902-.888a1.155 1.155 0 000-1.65 1.188 1.188 0 00-.84-.342z" fill="#1C3C3C"></path><path clip-rule="evenodd" d="M17.901 6H6.1C2.736 6 0 8.692 0 12s2.736 6 6.099 6H17.9C21.264 18 24 15.308 24 12s-2.736-6-6.099-6zm-5.821 9.407c-.195.04-.414.047-.562-.106-.045.1-.136.077-.221.056a.797.797 0 00-.061-.014c-.01.025-.017.048-.026.073-.329.021-.575-.309-.732-.558a4.991 4.991 0 00-.473-.21c-.172-.07-.345-.14-.509-.23a2.218 2.218 0 00-.004.173c-.002.244-.004.503-.227.651-.007.295.236.292.476.29.207-.003.41-.005.447.184a.485.485 0 01-.05.003c-.046 0-.092 0-.127.034-.117.111-.242.063-.372.013-.12-.046-.243-.094-.367-.02a2.318 2.318 0 00-.262.154.97.97 0 01-.548.194c-.024-.036-.014-.059.006-.08a.562.562 0 00.043-.056c.019-.028.035-.057.051-.084.054-.095.103-.18.242-.22-.185-.029-.344.055-.5.137l-.004.002a4.21 4.21 0 01-.065.034c-.097.04-.154.009-.212-.023-.082-.045-.168-.092-.376.04-.04-.032-.02-.061.002-.086.091-.109.21-.125.345-.119-.351-.193-.604-.056-.81.055-.182.098-.327.176-.471-.012-.065.017-.102.063-.138.108-.015.02-.03.038-.047.055-.035-.039-.027-.083-.018-.128l.005-.026a.242.242 0 00.003-.03l-.027-.01c-.053-.022-.105-.044-.09-.124-.117-.04-.2.03-.286.094-.054-.041-.01-.095.032-.145a.279.279 0 00.045-.065c.038-.065.103-.067.166-.069.054-.001.108-.003.145-.042.133-.075.297-.036.462.003.121.028.242.057.354.042.203.025.454-.18.352-.385-.186-.233-.184-.528-.183-.813v-.143c-.016-.108-.172-.233-.328-.358-.12-.095-.24-.191-.298-.28-.16-.177-.285-.382-.409-.585l-.015-.024c-.212-.404-.297-.86-.382-1.315-.103-.546-.205-1.09-.526-1.54-.266.144-.612.075-.841-.118-.12.107-.13.247-.138.396l-.001.014c-.297-.292-.26-.844-.023-1.17.097-.128.213-.233.342-.326.03-.021.04-.042.039-.074.235-1.04 1.836-.839 2.342-.103.167.206.281.442.395.678.137.283.273.566.5.795.22.237.452.463.684.689.359.35.718.699 1.032 1.089.49.587.839 1.276 1.144 1.97.05.092.08.193.11.293.044.15.089.299.2.417.026.035.084.088.149.148.156.143.357.328.289.409.009.019.027.04.05.06.032.028.074.058.116.088.122.087.25.178.16.25zm7.778-3.545l-.902.887c-.24.237-.537.413-.859.51l-.017.005-.006.015A2.021 2.021 0 0117.6 14l-.902.888c-.393.387-.916.6-1.474.6-.557 0-1.08-.213-1.474-.6a2.03 2.03 0 010-2.9l.902-.888c.242-.238.531-.409.859-.508l.016-.004.006-.016c.105-.272.265-.516.475-.724l.902-.887c.393-.387.917-.6 1.474-.6.558 0 1.08.213 1.474.6.394.387.61.902.61 1.45 0 .549-.216 1.064-.61 1.45v.001z" fill="#1C3C3C" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
1
frontend/public/Logos/llamaindex.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LlamaIndex</title><path d="M15.855 17.122c-2.092.924-4.358.545-5.23.24 0 .21-.01.857-.048 1.78-.038.924-.332 1.507-.475 1.684.016.577.029 1.837-.047 2.26a1.93 1.93 0 01-.476.914H8.295c.114-.577.555-.946.761-1.058.114-1.193-.11-2.229-.238-2.597-.126.449-.437 1.49-.665 2.068a6.418 6.418 0 01-.713 1.299h-.951c-.048-.578.27-.77.475-.77.095-.177.323-.731.476-1.54.152-.807-.064-2.324-.19-2.981v-2.068c-1.522-.818-2.092-1.636-2.473-2.55-.304-.73-.222-1.843-.142-2.308-.096-.176-.373-.625-.476-1.25-.142-.866-.063-1.491 0-1.828-.095-.096-.285-.587-.285-1.78 0-1.192.349-1.811.523-1.972v-.529c-.666-.048-1.331-.336-1.712-.721-.38-.385-.095-.962.143-1.154.238-.193.475-.049.808-.145.333-.096.618-.192.76-.48C4.512 1.403 4.287.448 4.16 0c.57.077.935.577 1.046.818V0c.713.337 1.997 1.154 2.425 2.934.342 1.424.586 4.409.665 5.723 1.823.016 4.137-.26 6.229.193 1.901.412 2.757 1.25 3.755 1.25.999 0 1.57-.577 2.282-.096.714.481 1.094 1.828.999 2.838-.076.808-.697 1.074-.998 1.106-.38 1.27 0 2.485.237 2.934v1.827c.111.16.333.655.333 1.347 0 .693-.222 1.154-.333 1.299.19 1.077-.08 2.18-.238 2.597h-1.283c.152-.385.412-.481.523-.481.228-1.193.063-2.293-.048-2.693-.722-.424-1.188-1.17-1.331-1.491.016.272-.029 1.029-.333 1.875-.304.847-.76 1.347-.95 1.491v1.01h-1.284c0-.615.348-.737.523-.721.222-.4.76-1.01.76-2.212 0-1.015-.713-1.492-1.236-2.405-.248-.434-.127-.978-.047-1.203z" fill="url(#lobe-icons-llama-index-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-llama-index-fill" x1="4.021" x2="24.613" y1="2.02" y2="19.277"><stop offset=".062" stop-color="#F6DCD9"></stop><stop offset=".326" stop-color="#FFA5EA"></stop><stop offset=".589" stop-color="#45DFF8"></stop><stop offset="1" stop-color="#BC8DEB"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
frontend/public/Logos/microsoft-sql-server.svg
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
2
frontend/public/Logos/nomad.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#06D092" d="M8 0L1 4v8l7 4 7-4V4L8 0zm3.119 8.797L9.254 9.863 7.001 8.65v2.549l-2.118 1.33v-5.33l1.68-1.018 2.332 1.216V4.794l2.23-1.322-.006 5.325z"/></svg>
|
||||
|
After Width: | Height: | Size: 389 B |
4
frontend/public/Logos/opentelemetry.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<path fill="#f5a800" d="M67.648 69.797c-5.246 5.25-5.246 13.758 0 19.008 5.25 5.246 13.758 5.246 19.004 0 5.25-5.25 5.25-13.758 0-19.008-5.246-5.246-13.754-5.246-19.004 0Zm14.207 14.219a6.649 6.649 0 0 1-9.41 0 6.65 6.65 0 0 1 0-9.407 6.649 6.649 0 0 1 9.41 0c2.598 2.586 2.598 6.809 0 9.407ZM86.43 3.672l-8.235 8.234a4.17 4.17 0 0 0 0 5.875l32.149 32.149a4.17 4.17 0 0 0 5.875 0l8.234-8.235c1.61-1.61 1.61-4.261 0-5.87L92.29 3.671a4.159 4.159 0 0 0-5.86 0ZM28.738 108.895a3.763 3.763 0 0 0 0-5.31l-4.183-4.187a3.768 3.768 0 0 0-5.313 0l-8.644 8.649-.016.012-2.371-2.375c-1.313-1.313-3.45-1.313-4.75 0-1.313 1.312-1.313 3.449 0 4.75l14.246 14.242a3.353 3.353 0 0 0 4.746 0c1.3-1.313 1.313-3.45 0-4.746l-2.375-2.375.016-.012Zm0 0"/>
|
||||
<path fill="#425cc7" d="M72.297 27.313 54.004 45.605c-1.625 1.625-1.625 4.301 0 5.926L65.3 62.824c7.984-5.746 19.18-5.035 26.363 2.153l9.148-9.149c1.622-1.625 1.622-4.297 0-5.922L78.22 27.313a4.185 4.185 0 0 0-5.922 0ZM60.55 67.585l-6.672-6.672c-1.563-1.562-4.125-1.562-5.684 0l-23.53 23.54a4.036 4.036 0 0 0 0 5.687l13.331 13.332a4.036 4.036 0 0 0 5.688 0l15.132-15.157c-3.199-6.609-2.625-14.593 1.735-20.73Zm0 0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
99
frontend/public/Logos/supabase.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
version="1.1"
|
||||
id="svg20"
|
||||
sodipodi:docname="supabase-icon.svg"
|
||||
style="fill:none"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
|
||||
<metadata
|
||||
id="metadata24">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1687"
|
||||
inkscape:window-height="849"
|
||||
id="namedview22"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.0884956"
|
||||
inkscape:cx="54.5"
|
||||
inkscape:cy="56.5"
|
||||
inkscape:window-x="70"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg20" />
|
||||
<path
|
||||
d="m 37.41219,62.936701 c -1.634985,2.05896 -4.950068,0.93085 -4.989463,-1.69817 L 31.846665,22.786035 h 25.855406 c 4.683108,0 7.294967,5.409033 4.382927,9.07673 z"
|
||||
id="path2"
|
||||
style="fill:url(#paint0_linear);stroke-width:0.57177335"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 37.41219,62.936701 c -1.634985,2.05896 -4.950068,0.93085 -4.989463,-1.69817 L 31.846665,22.786035 h 25.855406 c 4.683108,0 7.294967,5.409033 4.382927,9.07673 z"
|
||||
id="path4"
|
||||
style="fill:url(#paint1_linear);fill-opacity:0.2;stroke-width:0.57177335"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
d="m 26.89694,1.0634102 c 1.634986,-2.05918508 4.950125,-0.93090008 4.989521,1.698149 L 32.138899,41.214003 H 6.607076 c -4.6832501,0 -7.29518376,-5.409032 -4.3830007,-9.07673 z"
|
||||
id="path6"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#3ecf8e;stroke-width:0.57177335" />
|
||||
<defs
|
||||
id="defs18">
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="53.973801"
|
||||
y1="54.973999"
|
||||
x2="94.163498"
|
||||
y2="71.829498"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.57177306,0,0,0.57177334,0.98590077,-0.12074988)">
|
||||
<stop
|
||||
stop-color="#249361"
|
||||
id="stop8" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#3ECF8E"
|
||||
id="stop10" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
x1="36.1558"
|
||||
y1="30.577999"
|
||||
x2="54.484402"
|
||||
y2="65.080597"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.57177306,0,0,0.57177334,0.98590077,-0.12074988)">
|
||||
<stop
|
||||
id="stop13" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-opacity="0"
|
||||
id="stop15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -27,6 +27,7 @@ import { IUser } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { Route, Router, Switch } from 'react-router-dom';
|
||||
@@ -382,20 +383,22 @@ function App(): JSX.Element {
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
@@ -45,6 +44,12 @@
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
&::placeholder {
|
||||
color: #888 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCopyToClipboard } from 'react-use';
|
||||
function CopyClipboardHOC({
|
||||
entityKey,
|
||||
textToCopy,
|
||||
tooltipText = 'Copy to clipboard',
|
||||
children,
|
||||
}: CopyClipboardHOCProps): JSX.Element {
|
||||
const [value, setCopy] = useCopyToClipboard();
|
||||
@@ -31,7 +32,7 @@ function CopyClipboardHOC({
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>Copy to clipboard</span>}
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
@@ -42,7 +43,11 @@ function CopyClipboardHOC({
|
||||
interface CopyClipboardHOCProps {
|
||||
entityKey: string | undefined;
|
||||
textToCopy: string;
|
||||
tooltipText?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default CopyClipboardHOC;
|
||||
CopyClipboardHOC.defaultProps = {
|
||||
tooltipText: 'Copy to clipboard',
|
||||
};
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
|
||||
@@ -194,6 +194,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={config?.signalSource || ''}
|
||||
queriesCount={currentQuery.builder.queryData.length}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -236,6 +236,10 @@
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-activeLine > span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +275,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
.cm-placeholder {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #888 !important;
|
||||
|
||||
&.error {
|
||||
.cm-editor {
|
||||
@@ -231,6 +233,9 @@
|
||||
.query-aggregation-interval-input {
|
||||
input {
|
||||
max-width: 120px;
|
||||
&::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.add-trace-operator-button, .add-new-query-button, .add-formula-button {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './QueryFooter.styles.scss';
|
||||
|
||||
/* eslint-disable react/require-default-props */
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { DraftingCompass, Plus, Sigma } from 'lucide-react';
|
||||
@@ -22,8 +24,7 @@ export default function QueryFooter({
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn secondary"
|
||||
type="text"
|
||||
className="add-new-query-button periscope-btn "
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
/>
|
||||
@@ -49,7 +50,7 @@ export default function QueryFooter({
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn secondary"
|
||||
className="add-formula-button periscope-btn "
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
>
|
||||
@@ -77,7 +78,7 @@ export default function QueryFooter({
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-trace-operator-button periscope-btn secondary"
|
||||
className="add-trace-operator-button periscope-btn "
|
||||
icon={<DraftingCompass size={16} />}
|
||||
onClick={(): void => addTraceOperator?.()}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,12 @@ export const QueryV2 = memo(function QueryV2({
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
isMultiQueryAllowed = false,
|
||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||
queriesCount = 1,
|
||||
}: QueryProps & {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
// eslint-disable-next-line react/require-default-props
|
||||
queriesCount?: number;
|
||||
}): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
@@ -186,12 +191,16 @@ export const QueryV2 = memo(function QueryV2({
|
||||
icon: <Copy size={14} />,
|
||||
onClick: handleCloneEntity,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
key: 'delete-query',
|
||||
icon: <Trash size={14} />,
|
||||
onClick: handleDeleteQuery,
|
||||
},
|
||||
...(queriesCount && queriesCount > 1
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
key: 'delete-query',
|
||||
icon: <Trash size={14} />,
|
||||
onClick: handleDeleteQuery,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
|
||||
.qb-trace-operator-editor-container {
|
||||
flex: 1;
|
||||
.cm-activeLine > span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.arrow-left {
|
||||
@@ -113,6 +116,8 @@
|
||||
text-overflow: ellipsis;
|
||||
padding: 0px 8px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function TraceOperator({
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="label">TRACE OPERATOR</Typography.Text>
|
||||
<Typography.Text className="label">Trace Operator</Typography.Text>
|
||||
<div className="qb-trace-operator-editor-container">
|
||||
<TraceOperatorEditor
|
||||
value={traceOperator?.expression || ''}
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
&:hover {
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
&:not(.ant-radio-button-wrapper-disabled) {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
&:hover {
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
&::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,16 +58,18 @@
|
||||
// Light mode styles
|
||||
.lightMode {
|
||||
.signoz-radio-group {
|
||||
&.ant-radio-group-disabled {
|
||||
.tab,
|
||||
.selected_view {
|
||||
.tab,
|
||||
.selected_view {
|
||||
&.ant-radio-button-wrapper-disabled {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
color: var(--text-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.selected_view:hover {
|
||||
.tab:hover,
|
||||
.selected_view:hover {
|
||||
&:not(.ant-radio-button-wrapper-disabled) {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
color: var(--text-ink-400) !important;
|
||||
@@ -73,6 +77,7 @@
|
||||
}
|
||||
|
||||
.tab {
|
||||
border-color: var(--bg-vanilla-400) !important;
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const themeColors = {
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
chartcolors: {
|
||||
robin: '#3F5ECC',
|
||||
radicalRed: '#FF1A66',
|
||||
dodgerBlue: '#2F80ED',
|
||||
mediumOrchid: '#BB6BD9',
|
||||
seaBuckthorn: '#F2994A',
|
||||
@@ -58,7 +58,7 @@ const themeColors = {
|
||||
oliveDrab: '#66991A',
|
||||
lavenderRose: '#FF99E6',
|
||||
electricLime: '#CCFF1A',
|
||||
radicalRed: '#FF1A66',
|
||||
robin: '#3F5ECC',
|
||||
harleyOrange: '#E6331A',
|
||||
turquoise: '#33FFCC',
|
||||
gladeGreen: '#66994D',
|
||||
@@ -80,7 +80,7 @@ const themeColors = {
|
||||
maroon: '#800000',
|
||||
navy: '#000080',
|
||||
aquamarine: '#7FFFD4',
|
||||
gold: '#FFD700',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
gray: '#808080',
|
||||
skyBlue: '#87CEEB',
|
||||
indigo: '#4B0082',
|
||||
@@ -105,7 +105,7 @@ const themeColors = {
|
||||
lawnGreen: '#7CFC00',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
lightCoral: '#F08080',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
gold: '#FFD700',
|
||||
sandyBrown: '#F4A460',
|
||||
darkKhaki: '#BDB76B',
|
||||
cornflowerBlue: '#6495ED',
|
||||
@@ -113,7 +113,7 @@ const themeColors = {
|
||||
paleGreen: '#98FB98',
|
||||
},
|
||||
lightModeColor: {
|
||||
robin: '#3F5ECC',
|
||||
radicalRed: '#FF1A66',
|
||||
dodgerBlueDark: '#0C6EED',
|
||||
steelgrey: '#2f4b7c',
|
||||
steelpurple: '#665191',
|
||||
@@ -143,7 +143,7 @@ const themeColors = {
|
||||
oliveDrab: '#66991A',
|
||||
lavenderRoseDark: '#F024BD',
|
||||
electricLimeDark: '#84A800',
|
||||
radicalRed: '#FF1A66',
|
||||
robin: '#3F5ECC',
|
||||
harleyOrange: '#E6331A',
|
||||
gladeGreen: '#66994D',
|
||||
hemlock: '#66664D',
|
||||
@@ -181,7 +181,7 @@ const themeColors = {
|
||||
darkOrchid: '#9932CC',
|
||||
mediumSeaGreenDark: '#109E50',
|
||||
lightCoralDark: '#F85959',
|
||||
darkSeaGreenDark: '#509F50',
|
||||
gold: '#FFD700',
|
||||
sandyBrownDark: '#D97117',
|
||||
darkKhakiDark: '#99900A',
|
||||
cornflowerBlueDark: '#3371E6',
|
||||
|
||||
@@ -25,8 +25,8 @@ function QuerySection(): JSX.Element {
|
||||
|
||||
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
|
||||
|
||||
const onQueryCategoryChange = (val: EQueryType): void => {
|
||||
const query: Query = { ...currentQuery, queryType: val };
|
||||
const onQueryCategoryChange = (queryType: EQueryType): void => {
|
||||
const query: Query = { ...currentQuery, queryType };
|
||||
redirectWithQueryBuilderData(query);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,16 +2,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialClickHouseData,
|
||||
initialQueryPromQLData,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import QuerySection from '../QuerySection';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-uuid-12345',
|
||||
}));
|
||||
|
||||
const MOCK_UUID = 'test-uuid-12345';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
@@ -48,12 +60,27 @@ jest.mock(
|
||||
queryCategory,
|
||||
alertType,
|
||||
panelType,
|
||||
setQueryCategory,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<div data-testid="query-section-component">
|
||||
<div data-testid="query-category">{queryCategory}</div>
|
||||
<div data-testid="alert-type">{alertType}</div>
|
||||
<div data-testid="panel-type">{panelType}</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="change-to-promql"
|
||||
onClick={(): void => setQueryCategory(EQueryType.PROM)}
|
||||
>
|
||||
Change to PromQL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="change-to-query-builder"
|
||||
onClick={(): void => setQueryCategory(EQueryType.QUERY_BUILDER)}
|
||||
>
|
||||
Change to Query Builder
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -240,17 +267,6 @@ describe('QuerySection', () => {
|
||||
expect(screen.getByTestId('panel-type')).toHaveTextContent('graph');
|
||||
});
|
||||
|
||||
it('has correct CSS classes for tab styling', () => {
|
||||
renderQuerySection();
|
||||
|
||||
const tabs = screen.getAllByRole('button');
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
expect(tab).toHaveClass('list-view-tab');
|
||||
expect(tab).toHaveClass('explorer-view-option');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with correct container structure', () => {
|
||||
renderQuerySection();
|
||||
|
||||
@@ -307,4 +323,172 @@ describe('QuerySection', () => {
|
||||
expect(metricsButton).toHaveClass(ACTIVE_TAB_CLASS);
|
||||
expect(logsButton).not.toHaveClass(ACTIVE_TAB_CLASS);
|
||||
});
|
||||
|
||||
it('updates the query data when the alert type changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection();
|
||||
|
||||
const metricsTab = screen.getByText(METRICS_TEXT);
|
||||
await user.click(metricsTab);
|
||||
|
||||
const result = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
id: MOCK_UUID,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
builder: {
|
||||
queryData: [
|
||||
expect.objectContaining({
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
}),
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [initialQueryPromQLData],
|
||||
clickhouse_sql: [initialClickHouseData],
|
||||
});
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the query data when the query type changes from query_builder to promql', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection();
|
||||
|
||||
const changeToPromQLButton = screen.getByTestId('change-to-promql');
|
||||
await user.click(changeToPromQLButton);
|
||||
|
||||
expect(
|
||||
mockUseQueryBuilder.redirectWithQueryBuilderData,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [
|
||||
queryArg,
|
||||
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
expect(queryArg).toEqual({
|
||||
...mockUseQueryBuilder.currentQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
});
|
||||
|
||||
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
queryArg,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the query data when switching from promql to query_builder for logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockCurrentQueryWithPromQL = {
|
||||
...mockUseQueryBuilder.currentQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
useQueryBuilder.mockReturnValue({
|
||||
...mockUseQueryBuilder,
|
||||
currentQuery: mockCurrentQueryWithPromQL,
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.LOGS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const changeToQueryBuilderButton = screen.getByTestId(
|
||||
'change-to-query-builder',
|
||||
);
|
||||
await user.click(changeToQueryBuilderButton);
|
||||
|
||||
expect(
|
||||
mockUseQueryBuilder.redirectWithQueryBuilderData,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [
|
||||
queryArg,
|
||||
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
expect(queryArg).toEqual({
|
||||
...mockCurrentQueryWithPromQL,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
});
|
||||
|
||||
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
queryArg,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the query data when switching from clickhouse_sql to query_builder for traces', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockCurrentQueryWithClickhouseSQL = {
|
||||
...mockUseQueryBuilder.currentQuery,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
useQueryBuilder.mockReturnValue({
|
||||
...mockUseQueryBuilder,
|
||||
currentQuery: mockCurrentQueryWithClickhouseSQL,
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.TRACES_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const changeToQueryBuilderButton = screen.getByTestId(
|
||||
'change-to-query-builder',
|
||||
);
|
||||
await user.click(changeToQueryBuilderButton);
|
||||
|
||||
expect(
|
||||
mockUseQueryBuilder.redirectWithQueryBuilderData,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [
|
||||
queryArg,
|
||||
] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0];
|
||||
|
||||
expect(queryArg).toEqual({
|
||||
...mockCurrentQueryWithClickhouseSQL,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
});
|
||||
|
||||
expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
queryArg,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
alertDefaults,
|
||||
anamolyAlertDefaults,
|
||||
exceptionAlertDefaults,
|
||||
logAlertDefaults,
|
||||
traceAlertDefaults,
|
||||
} from 'container/CreateAlertRule/defaults';
|
||||
import dayjs from 'dayjs';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from '../constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertState,
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
TimeDuration,
|
||||
} from '../types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertType,
|
||||
getInitialAlertTypeFromURL,
|
||||
notificationSettingsReducer,
|
||||
} from '../utils';
|
||||
|
||||
const UNKNOWN_ACTION_TYPE = 'UNKNOWN_ACTION_TYPE';
|
||||
const TEST_RESET_TO_INITIAL_STATE = 'should reset to initial state';
|
||||
const TEST_SET_INITIAL_STATE_FROM_PAYLOAD =
|
||||
'should set initial state from payload';
|
||||
const TEST_RETURN_STATE_FOR_UNKNOWN_ACTION =
|
||||
'should return current state for unknown action';
|
||||
|
||||
describe('CreateAlertV2 Context Utils', () => {
|
||||
describe('alertCreationReducer', () => {
|
||||
it('should set alert name', () => {
|
||||
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
|
||||
type: 'SET_ALERT_NAME',
|
||||
payload: 'Test Alert',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_STATE,
|
||||
name: 'Test Alert',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set alert labels', () => {
|
||||
const labels = { severity: 'critical', team: 'backend' };
|
||||
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
|
||||
type: 'SET_ALERT_LABELS',
|
||||
payload: labels,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_STATE,
|
||||
labels,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set y-axis unit', () => {
|
||||
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
|
||||
type: 'SET_Y_AXIS_UNIT',
|
||||
payload: 'ms',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_STATE,
|
||||
yAxisUnit: 'ms',
|
||||
});
|
||||
});
|
||||
|
||||
it(TEST_RESET_TO_INITIAL_STATE, () => {
|
||||
const modifiedState: AlertState = {
|
||||
name: 'Modified',
|
||||
labels: { test: 'value' },
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
const result = alertCreationReducer(modifiedState, { type: 'RESET' });
|
||||
expect(result).toEqual(INITIAL_ALERT_STATE);
|
||||
});
|
||||
|
||||
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
|
||||
const newState: AlertState = {
|
||||
name: 'Custom Alert',
|
||||
labels: { env: 'production' },
|
||||
yAxisUnit: 'bytes',
|
||||
};
|
||||
const result = alertCreationReducer(INITIAL_ALERT_STATE, {
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: newState,
|
||||
});
|
||||
expect(result).toEqual(newState);
|
||||
});
|
||||
|
||||
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
|
||||
const result = alertCreationReducer(
|
||||
INITIAL_ALERT_STATE,
|
||||
|
||||
{ type: UNKNOWN_ACTION_TYPE } as any,
|
||||
);
|
||||
expect(result).toEqual(INITIAL_ALERT_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialAlertType', () => {
|
||||
it('should return METRICS_BASED_ALERT for metrics data source', () => {
|
||||
const result = getInitialAlertType(initialQueriesMap.metrics);
|
||||
expect(result).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should return LOGS_BASED_ALERT for logs data source', () => {
|
||||
const result = getInitialAlertType(initialQueriesMap.logs);
|
||||
expect(result).toBe(AlertTypes.LOGS_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should return TRACES_BASED_ALERT for traces data source', () => {
|
||||
const result = getInitialAlertType(initialQueriesMap.traces);
|
||||
expect(result).toBe(AlertTypes.TRACES_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should return METRICS_BASED_ALERT for unknown data source', () => {
|
||||
const queryWithUnknownDataSource = {
|
||||
...initialQueriesMap.metrics,
|
||||
builder: {
|
||||
...initialQueriesMap.metrics.builder,
|
||||
queryData: [],
|
||||
},
|
||||
};
|
||||
const result = getInitialAlertType(queryWithUnknownDataSource);
|
||||
expect(result).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInitialAlertDef', () => {
|
||||
it('should return logAlertDefaults for LOGS_BASED_ALERT', () => {
|
||||
const result = buildInitialAlertDef(AlertTypes.LOGS_BASED_ALERT);
|
||||
expect(result).toBe(logAlertDefaults);
|
||||
});
|
||||
|
||||
it('should return traceAlertDefaults for TRACES_BASED_ALERT', () => {
|
||||
const result = buildInitialAlertDef(AlertTypes.TRACES_BASED_ALERT);
|
||||
expect(result).toBe(traceAlertDefaults);
|
||||
});
|
||||
|
||||
it('should return exceptionAlertDefaults for EXCEPTIONS_BASED_ALERT', () => {
|
||||
const result = buildInitialAlertDef(AlertTypes.EXCEPTIONS_BASED_ALERT);
|
||||
expect(result).toBe(exceptionAlertDefaults);
|
||||
});
|
||||
|
||||
it('should return anamolyAlertDefaults for ANOMALY_BASED_ALERT', () => {
|
||||
const result = buildInitialAlertDef(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
expect(result).toBe(anamolyAlertDefaults);
|
||||
});
|
||||
|
||||
it('should return alertDefaults for METRICS_BASED_ALERT', () => {
|
||||
const result = buildInitialAlertDef(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(result).toBe(alertDefaults);
|
||||
});
|
||||
|
||||
it('should return alertDefaults for unknown alert type', () => {
|
||||
const result = buildInitialAlertDef('UNKNOWN' as AlertTypes);
|
||||
expect(result).toBe(alertDefaults);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialAlertTypeFromURL', () => {
|
||||
it('should return ANOMALY_BASED_ALERT when ruleType is anomaly_rule', () => {
|
||||
const params = new URLSearchParams('?ruleType=anomaly_rule');
|
||||
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
|
||||
expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should return alert type from alertType param', () => {
|
||||
const params = new URLSearchParams('?alertType=LOGS_BASED_ALERT');
|
||||
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
|
||||
expect(result).toBe(AlertTypes.LOGS_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should prioritize ruleType over alertType', () => {
|
||||
const params = new URLSearchParams(
|
||||
'?ruleType=anomaly_rule&alertType=LOGS_BASED_ALERT',
|
||||
);
|
||||
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics);
|
||||
expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('should fall back to query data source when no URL params', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const result = getInitialAlertTypeFromURL(params, initialQueriesMap.traces);
|
||||
expect(result).toBe(AlertTypes.TRACES_BASED_ALERT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertThresholdReducer', () => {
|
||||
it('should set selected query', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: 'B',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
selectedQuery: 'B',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set operator', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_OPERATOR',
|
||||
payload: AlertThresholdOperator.IS_BELOW,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set match type', () => {
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set thresholds', () => {
|
||||
const newThresholds = [
|
||||
{
|
||||
id: '1',
|
||||
label: 'critical',
|
||||
thresholdValue: 100,
|
||||
recoveryThresholdValue: 90,
|
||||
unit: 'ms',
|
||||
channels: ['channel1'],
|
||||
color: '#FF0000',
|
||||
},
|
||||
];
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: newThresholds,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds: newThresholds,
|
||||
});
|
||||
});
|
||||
|
||||
it(TEST_RESET_TO_INITIAL_STATE, () => {
|
||||
const modifiedState: AlertThresholdState = {
|
||||
selectedQuery: 'B',
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
evaluationWindow: TimeDuration.TEN_MINUTES,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [],
|
||||
};
|
||||
const result = alertThresholdReducer(modifiedState, { type: 'RESET' });
|
||||
expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE);
|
||||
});
|
||||
|
||||
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
|
||||
const newState: AlertThresholdState = {
|
||||
selectedQuery: 'C',
|
||||
operator: AlertThresholdOperator.IS_EQUAL_TO,
|
||||
matchType: AlertThresholdMatchType.ON_AVERAGE,
|
||||
evaluationWindow: TimeDuration.ONE_HOUR,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.WEEKLY,
|
||||
thresholds: [],
|
||||
};
|
||||
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: newState,
|
||||
});
|
||||
expect(result).toEqual(newState);
|
||||
});
|
||||
|
||||
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
|
||||
const result = alertThresholdReducer(
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
|
||||
{ type: UNKNOWN_ACTION_TYPE } as any,
|
||||
);
|
||||
expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedOptionsReducer', () => {
|
||||
it('should set send notification if data is missing', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: { toleranceLimit: 21, timeUnit: UniversalYAxisUnit.HOURS },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
|
||||
toleranceLimit: 21,
|
||||
timeUnit: UniversalYAxisUnit.HOURS,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle send notification if data is missing', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set enforce minimum datapoints', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: { minimumDatapoints: 10 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
enforceMinimumDatapoints: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
|
||||
minimumDatapoints: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle enforce minimum datapoints', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
|
||||
payload: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
enforceMinimumDatapoints: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set delay evaluation', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_DELAY_EVALUATION',
|
||||
payload: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set evaluation cadence', () => {
|
||||
const newCadence = {
|
||||
default: { value: 5, timeUnit: UniversalYAxisUnit.HOURS },
|
||||
custom: {
|
||||
repeatEvery: 'week',
|
||||
startAt: '12:00:00',
|
||||
timezone: 'America/New_York',
|
||||
occurence: ['Monday', 'Friday'],
|
||||
},
|
||||
rrule: { date: dayjs(), startAt: '10:00:00', rrule: 'test-rrule' },
|
||||
};
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_EVALUATION_CADENCE',
|
||||
payload: newCadence,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
...newCadence,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set evaluation cadence mode', () => {
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_EVALUATION_CADENCE_MODE',
|
||||
payload: 'custom',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationCadence: {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
|
||||
mode: 'custom',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it(TEST_RESET_TO_INITIAL_STATE, () => {
|
||||
const modifiedState: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS },
|
||||
};
|
||||
const result = advancedOptionsReducer(modifiedState, { type: 'RESET' });
|
||||
expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE);
|
||||
});
|
||||
|
||||
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
|
||||
const newState: AdvancedOptionsState = {
|
||||
...INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 45,
|
||||
timeUnit: UniversalYAxisUnit.SECONDS,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, {
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: newState,
|
||||
});
|
||||
expect(result).toEqual(newState);
|
||||
});
|
||||
|
||||
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
|
||||
const result = advancedOptionsReducer(
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
|
||||
{ type: UNKNOWN_ACTION_TYPE } as any,
|
||||
);
|
||||
expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluationWindowReducer', () => {
|
||||
it('should set window type to rolling and reset timeframe', () => {
|
||||
const modifiedState: EvaluationWindowState = {
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
};
|
||||
const result = evaluationWindowReducer(modifiedState, {
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'rolling',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
windowType: 'rolling',
|
||||
timeframe: INITIAL_EVALUATION_WINDOW_STATE.timeframe,
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set window type to cumulative and set timeframe to currentHour', () => {
|
||||
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
|
||||
type: 'SET_WINDOW_TYPE',
|
||||
payload: 'cumulative',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set timeframe', () => {
|
||||
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
|
||||
type: 'SET_TIMEFRAME',
|
||||
payload: '10m0s',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
timeframe: '10m0s',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set starting at', () => {
|
||||
const newStartingAt = {
|
||||
time: '14:30:00',
|
||||
number: '5',
|
||||
timezone: 'Europe/London',
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
};
|
||||
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
|
||||
type: 'SET_STARTING_AT',
|
||||
payload: newStartingAt,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...INITIAL_EVALUATION_WINDOW_STATE,
|
||||
startingAt: newStartingAt,
|
||||
});
|
||||
});
|
||||
|
||||
it(TEST_RESET_TO_INITIAL_STATE, () => {
|
||||
const modifiedState: EvaluationWindowState = {
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
startingAt: {
|
||||
time: '12:00:00',
|
||||
number: '2',
|
||||
timezone: 'America/New_York',
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
},
|
||||
};
|
||||
const result = evaluationWindowReducer(modifiedState, { type: 'RESET' });
|
||||
expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE);
|
||||
});
|
||||
|
||||
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
|
||||
const newState: EvaluationWindowState = {
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentDay',
|
||||
startingAt: {
|
||||
time: '09:00:00',
|
||||
number: '3',
|
||||
timezone: 'Asia/Tokyo',
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
},
|
||||
};
|
||||
const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, {
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: newState,
|
||||
});
|
||||
expect(result).toEqual(newState);
|
||||
});
|
||||
|
||||
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
|
||||
const result = evaluationWindowReducer(
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
|
||||
{ type: UNKNOWN_ACTION_TYPE } as any,
|
||||
);
|
||||
expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notificationSettingsReducer', () => {
|
||||
it('should set multiple notifications', () => {
|
||||
const notifications = ['channel1', 'channel2', 'channel3'];
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
{
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: notifications,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
multipleNotifications: notifications,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set multiple notifications to null', () => {
|
||||
const modifiedState = {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
multipleNotifications: ['channel1', 'channel2'],
|
||||
};
|
||||
const result = notificationSettingsReducer(modifiedState, {
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: null,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
...modifiedState,
|
||||
multipleNotifications: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set re-notification', () => {
|
||||
const reNotification = {
|
||||
enabled: true,
|
||||
value: 60,
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
conditions: ['firing' as const, 'nodata' as const],
|
||||
};
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
{
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: reNotification,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
reNotification,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set description', () => {
|
||||
const description = 'Custom alert description with {{$value}}';
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
{
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: description,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
description,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set routing policies', () => {
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
{
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
payload: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
routingPolicies: true,
|
||||
});
|
||||
});
|
||||
|
||||
it(TEST_RESET_TO_INITIAL_STATE, () => {
|
||||
const modifiedState: NotificationSettingsState = {
|
||||
multipleNotifications: ['channel1'],
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 120,
|
||||
unit: UniversalYAxisUnit.HOURS,
|
||||
conditions: ['firing'],
|
||||
},
|
||||
description: 'Modified description',
|
||||
routingPolicies: true,
|
||||
};
|
||||
const result = notificationSettingsReducer(modifiedState, {
|
||||
type: 'RESET',
|
||||
});
|
||||
expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE);
|
||||
});
|
||||
|
||||
it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => {
|
||||
const newState: NotificationSettingsState = {
|
||||
multipleNotifications: ['channel4', 'channel5'],
|
||||
reNotification: {
|
||||
enabled: true,
|
||||
value: 90,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: ['nodata'],
|
||||
},
|
||||
description: 'New description',
|
||||
routingPolicies: true,
|
||||
};
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
{
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: newState,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(newState);
|
||||
});
|
||||
|
||||
it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => {
|
||||
const result = notificationSettingsReducer(
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
|
||||
{ type: UNKNOWN_ACTION_TYPE } as any,
|
||||
);
|
||||
expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,13 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
|
||||
export function CreateAlertProvider(
|
||||
props: ICreateAlertProviderProps,
|
||||
): JSX.Element {
|
||||
const { children, initialAlertState, isEditMode, ruleId } = props;
|
||||
const {
|
||||
children,
|
||||
initialAlertState,
|
||||
isEditMode,
|
||||
ruleId,
|
||||
initialAlertType,
|
||||
} = props;
|
||||
|
||||
const [alertState, setAlertState] = useReducer(
|
||||
alertCreationReducer,
|
||||
@@ -62,9 +68,12 @@ export function CreateAlertProvider(
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() =>
|
||||
getInitialAlertTypeFromURL(queryParams, currentQuery),
|
||||
);
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
return initialAlertType;
|
||||
}
|
||||
return getInitialAlertTypeFromURL(queryParams, currentQuery);
|
||||
});
|
||||
|
||||
const handleAlertTypeChange = useCallback(
|
||||
(value: AlertTypes): void => {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const alertCreationReducer = (
|
||||
|
||||
export function getInitialAlertType(currentQuery: Query): AlertTypes {
|
||||
const dataSource =
|
||||
currentQuery.builder.queryData[0].dataSource || DataSource.METRICS;
|
||||
currentQuery.builder.queryData?.[0]?.dataSource || DataSource.METRICS;
|
||||
switch (dataSource) {
|
||||
case DataSource.METRICS:
|
||||
return AlertTypes.METRICS_BASED_ALERT;
|
||||
|
||||
16
frontend/src/container/EditAlertV2/utils.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export function sanitizeDefaultAlertQuery(
|
||||
query: Query,
|
||||
alertType: AlertTypes,
|
||||
): Query {
|
||||
// If there are no queries, add a default one based on the alert type
|
||||
if (query.builder.queryData.length === 0) {
|
||||
const dataSource = ALERTS_DATA_SOURCE_MAP[alertType];
|
||||
query.builder.queryData.push(initialQueryBuilderFormValuesMap[dataSource]);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
||||
import { Col, Typography } from 'antd';
|
||||
import { StyledCol, StyledRow } from 'components/Styled';
|
||||
import { IIntervalUnit } from 'container/TraceDetail/utils';
|
||||
import {
|
||||
IIntervalUnit,
|
||||
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||
} from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEventHandler,
|
||||
|
||||
@@ -23,6 +23,7 @@ import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
@@ -36,6 +37,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { GettableAlert } from 'types/api/alerts/get';
|
||||
|
||||
import DeleteAlert from './DeleteAlert';
|
||||
@@ -141,7 +143,10 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
];
|
||||
|
||||
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
|
||||
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(record.condition.compositeQuery),
|
||||
record.alertType as AlertTypes,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
"imgUrl": "/Logos/signoz-brand-logo.svg",
|
||||
"link": "https://signoz.io/docs/migration/migrate-from-signoz-self-host-to-signoz-cloud/"
|
||||
},
|
||||
{
|
||||
"dataSource": "migrate-from-existing-opentelemetry",
|
||||
"label": "From Existing OpenTelemetry",
|
||||
"tags": ["migrate to SigNoz"],
|
||||
"module": "home",
|
||||
"relatedSearchKeywords": [
|
||||
"apm migration",
|
||||
"opentelemetry",
|
||||
"migration guide",
|
||||
"migrate",
|
||||
"migration"
|
||||
],
|
||||
"imgUrl": "/Logos/opentelemetry.svg",
|
||||
"link": "https://signoz.io/docs/migration/migrate-from-opentelemetry-to-signoz/"
|
||||
},
|
||||
{
|
||||
"dataSource": "java",
|
||||
"entityID": "dataSource",
|
||||
@@ -2656,6 +2671,156 @@
|
||||
],
|
||||
"link": "https://signoz.io/docs/community/llm-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "anthropic-api",
|
||||
"label": "Anthropic API",
|
||||
"imgUrl": "/Logos/anthropic-api-monitoring.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"llm monitoring",
|
||||
"large language model observability",
|
||||
"monitor anthropic",
|
||||
"llm response time tracing",
|
||||
"llm metrics",
|
||||
"otel llm integration",
|
||||
"llm performance tracking",
|
||||
"metrics",
|
||||
"traces",
|
||||
"logs"
|
||||
],
|
||||
"link": "https://signoz.io/docs/anthropic-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "claude-code",
|
||||
"label": "Claude Code",
|
||||
"imgUrl": "/Logos/claude-code.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"claude code monitoring",
|
||||
"claude code observability",
|
||||
"claude code performance tracking",
|
||||
"claude code latency tracing",
|
||||
"claude code metrics",
|
||||
"otel claude integration",
|
||||
"claude code response time",
|
||||
"claude code logs",
|
||||
"claude code error tracking",
|
||||
"claude code debugging",
|
||||
"metrics",
|
||||
"logs"
|
||||
],
|
||||
"link": "https://signoz.io/docs/claude-code-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "deepseek-api",
|
||||
"label": "DeepSeek API",
|
||||
"imgUrl": "/Logos/deepseek.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"deepseek api monitoring",
|
||||
"deepseek api observability",
|
||||
"deepseek api performance tracking",
|
||||
"deepseek api latency tracing",
|
||||
"deepseek api metrics",
|
||||
"otel deepseek integration",
|
||||
"deepseek api response time",
|
||||
"deepseek api logs",
|
||||
"deepseek api error tracking",
|
||||
"deepseek api debugging",
|
||||
"metrics",
|
||||
"logs"
|
||||
],
|
||||
"link": "https://signoz.io/docs/deepseek-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "google-gemini-api",
|
||||
"label": "Google Gemini",
|
||||
"imgUrl": "/Logos/google-gemini.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"google gemini api monitoring",
|
||||
"google gemini api observability",
|
||||
"google gemini api performance tracking",
|
||||
"google gemini api latency tracing",
|
||||
"google gemini api metrics",
|
||||
"otel google gemini integration",
|
||||
"google gemini api response time",
|
||||
"google gemini api logs",
|
||||
"google gemini api error tracking",
|
||||
"google gemini api debugging",
|
||||
"gemini",
|
||||
"metrics",
|
||||
"logs"
|
||||
],
|
||||
"link": "https://signoz.io/docs/google-gemini-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "langchain",
|
||||
"label": "LangChain",
|
||||
"imgUrl": "/Logos/langchain.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"langchain monitoring",
|
||||
"langchain observability",
|
||||
"langchain performance tracking",
|
||||
"langchain latency tracing",
|
||||
"langchain metrics",
|
||||
"otel langchain integration",
|
||||
"langchain response time",
|
||||
"langchain logs",
|
||||
"langchain error tracking",
|
||||
"langchain debugging",
|
||||
"traces"
|
||||
],
|
||||
"link": "https://signoz.io/docs/langchain-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "llamaindex",
|
||||
"label": "LlamaIndex",
|
||||
"imgUrl": "/Logos/llamaindex.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"llamaindex monitoring",
|
||||
"llamaindex observability",
|
||||
"llamaindex performance tracking",
|
||||
"llamaindex latency tracing",
|
||||
"llamaindex metrics",
|
||||
"otel llamaindex integration",
|
||||
"llamaindex response time",
|
||||
"llamaindex logs",
|
||||
"llamaindex error tracking",
|
||||
"llamaindex debugging",
|
||||
"traces"
|
||||
],
|
||||
"link": "https://signoz.io/docs/llamaindex-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "vercel-ai-sdk",
|
||||
"label": "Vercel AI SDK",
|
||||
"imgUrl": "/Logos/vercel.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"vercel ai sdk monitoring",
|
||||
"vercel ai sdk observability",
|
||||
"vercel ai sdk performance tracking",
|
||||
"vercel ai sdk latency tracing",
|
||||
"vercel ai sdk metrics",
|
||||
"otel vercel ai sdk integration",
|
||||
"vercel ai sdk response time",
|
||||
"vercel ai sdk logs",
|
||||
"vercel ai sdk error tracking",
|
||||
"vercel ai sdk debugging",
|
||||
"traces"
|
||||
],
|
||||
"link": "https://signoz.io/docs/vercel-ai-sdk-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "http-endpoints-monitoring",
|
||||
"label": "HTTP Endpoints Monitoring",
|
||||
@@ -3391,5 +3556,58 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"dataSource": "microsoft-sql-server",
|
||||
"label": "Microsoft SQL Server",
|
||||
"imgUrl": "/Logos/microsoft-sql-server.svg",
|
||||
"tags": ["integrations"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"sql server metrics",
|
||||
"mssql monitoring",
|
||||
"sql server performance",
|
||||
"sql server observability",
|
||||
"Microsoft",
|
||||
"sql server logs",
|
||||
"metrics",
|
||||
"logs"
|
||||
],
|
||||
"id": "microsoft-sql-server",
|
||||
"link": "https://signoz.io/docs/integrations/sql-server/"
|
||||
},
|
||||
{
|
||||
"dataSource": "supabase",
|
||||
"label": "Supabase",
|
||||
"imgUrl": "/Logos/supabase.svg",
|
||||
"tags": ["integrations"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"supabase metrics",
|
||||
"supabase monitoring",
|
||||
"supabase performance",
|
||||
"supabase observability",
|
||||
"supabase",
|
||||
"metrics"
|
||||
],
|
||||
"id": "supabase",
|
||||
"link": "https://signoz.io/docs/integrations/supabase/"
|
||||
},
|
||||
{
|
||||
"dataSource": "nomad",
|
||||
"label": "Nomad",
|
||||
"imgUrl": "/Logos/nomad.svg",
|
||||
"tags": ["integrations"],
|
||||
"module": "metrics",
|
||||
"relatedSearchKeywords": [
|
||||
"nomad metrics",
|
||||
"nomad monitoring",
|
||||
"nomad performance",
|
||||
"nomad observability",
|
||||
"nomad",
|
||||
"metrics"
|
||||
],
|
||||
"id": "nomad",
|
||||
"link": "https://signoz.io/docs/integrations/nomad/"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { AxiosResponse } from 'axios';
|
||||
@@ -55,11 +56,10 @@ const useOptionsMenu = ({
|
||||
initialOptions = {},
|
||||
}: UseOptionsMenuProps): UseOptionsMenu => {
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
preferences,
|
||||
updateColumns,
|
||||
updateFormatting,
|
||||
} = usePreferenceContext();
|
||||
const prefCtx = usePreferenceContext();
|
||||
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
|
||||
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
|
||||
const { preferences, updateColumns, updateFormatting } = slice;
|
||||
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -48,7 +47,6 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
const traceFlamegraphState = useMemo(() => {
|
||||
@@ -132,36 +130,40 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
>
|
||||
<div className="exec-time-service">% exec time</div>
|
||||
<div className="stats">
|
||||
{Object.keys(serviceExecTime).map((service) => {
|
||||
const spread = endTime - startTime;
|
||||
const value = (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(
|
||||
service,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" ellipsis>
|
||||
{service}
|
||||
{Object.keys(serviceExecTime)
|
||||
.sort((a, b) => {
|
||||
const spread = endTime - startTime;
|
||||
const aValue = (serviceExecTime[a] * 100) / spread;
|
||||
const bValue = (serviceExecTime[b] * 100) / spread;
|
||||
return bValue - aValue;
|
||||
})
|
||||
.map((service) => {
|
||||
const spread = endTime - startTime;
|
||||
const value = (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(service, themeColors.traceDetailColors);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" ellipsis>
|
||||
{service}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -57,7 +57,7 @@ function ResourceAttributesFilter(): JSX.Element | null {
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
operatorConfigKey={OperatorConfigKeys.EXCEPTIONS}
|
||||
hideSpanScopeSelector={false}
|
||||
hideSpanScopeSelector
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||
import {
|
||||
@@ -124,7 +125,7 @@ export default function AttributeActions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="action-btn">
|
||||
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
padding-block: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
@@ -25,8 +25,10 @@
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-500);
|
||||
.action-btn {
|
||||
display: flex;
|
||||
}
|
||||
@@ -81,22 +83,23 @@
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
|
||||
&--is-open {
|
||||
display: flex;
|
||||
}
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-color: var(--bg-slate-400);
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-400);
|
||||
background: var(--bg-slate-500);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
@@ -129,7 +132,7 @@
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +145,7 @@
|
||||
.ant-popover-inner {
|
||||
padding: 8px;
|
||||
min-width: 160px;
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +153,9 @@
|
||||
.attributes-corner {
|
||||
.attributes-container {
|
||||
.item {
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
.item-key {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
@@ -163,8 +170,6 @@
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './Attributes.styles.scss';
|
||||
|
||||
import { Input, Tooltip, Typography } from 'antd';
|
||||
import { Input, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { flattenObject } from 'container/LogDetailedView/utils';
|
||||
@@ -82,37 +82,41 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
<section
|
||||
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
|
||||
>
|
||||
{datasource.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" ellipsis>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.value}>
|
||||
{datasource
|
||||
.filter((item) => !!item.value)
|
||||
.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" ellipsis>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<CopyClipboardHOC
|
||||
entityKey={item.value}
|
||||
textToCopy={item.value}
|
||||
tooltipText={item.value}
|
||||
>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -219,6 +219,12 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.ant-tabs-content-holder {
|
||||
.bg-border {
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
.span-details-drawer {
|
||||
border-left: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from 'lucide-react';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -212,24 +211,20 @@ function SpanLogs({
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className="span-logs-list-container">
|
||||
<PreferenceContextProvider>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={
|
||||
logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}
|
||||
}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</PreferenceContextProvider>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
),
|
||||
[logs, getItemContent, renderFooter],
|
||||
|
||||
@@ -261,18 +261,16 @@ describe('SpanDetailsDrawer', () => {
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs view to open
|
||||
// Wait for logs view to open and logs to be displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logs are displayed
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -285,12 +283,9 @@ describe('SpanDetailsDrawer', () => {
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Verify the three distinct queries were made
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
@@ -319,12 +314,9 @@ describe('SpanDetailsDrawer', () => {
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
|
||||
@@ -484,9 +476,17 @@ describe('SpanDetailsDrawer', () => {
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
// Wait for all API calls to complete first
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// Wait for all logs to be rendered - both span logs and context logs
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify span logs are highlighted
|
||||
|
||||
@@ -24,7 +24,6 @@ import { spanServiceNameToColorMapping } from 'lib/getRandomColor';
|
||||
import history from 'lib/history';
|
||||
import { map } from 'lodash-es';
|
||||
import { PanelRight } from 'lucide-react';
|
||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ITraceForest, PayloadProps } from 'types/api/trace/getTraceItem';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
getTreeLevelsCount,
|
||||
IIntervalUnit,
|
||||
INTERVAL_UNITS,
|
||||
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||
} from './utils';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
@@ -13,6 +13,8 @@ export const filterSpansByString = (
|
||||
return JSON.stringify(spanWithoutChildren).includes(searchString);
|
||||
});
|
||||
|
||||
export const SPAN_DETAILS_LEFT_COL_WIDTH = 350;
|
||||
|
||||
type TTimeUnitName = 'ms' | 's' | 'm' | 'hr' | 'day' | 'week';
|
||||
|
||||
export interface IIntervalUnit {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -13,16 +12,7 @@ import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from 'lucide-react';
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Dispatch, memo, SetStateAction, useEffect, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
@@ -30,7 +20,6 @@ import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
import { transformBuilderQueryFields } from 'utils/queryTransformers';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
@@ -43,13 +32,13 @@ interface TracesViewProps {
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
|
||||
AppState,
|
||||
@@ -60,26 +49,9 @@ function TracesView({
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() =>
|
||||
transformBuilderQueryFields(stagedQuery || initialQueriesMap.traces, {
|
||||
orderBy: [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
],
|
||||
}),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
@@ -100,7 +72,6 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
@@ -148,18 +119,6 @@ function TracesView({
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const useGetAllViews = (
|
||||
sourcepage: DataSource | 'meter',
|
||||
enabled?: boolean,
|
||||
): UseQueryResult<AxiosResponse<AllViewsProps>, AxiosError> =>
|
||||
useQuery<AxiosResponse<AllViewsProps>, AxiosError>({
|
||||
queryKey: [{ sourcepage }],
|
||||
queryFn: () => getAllViews(sourcepage as DataSource),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
});
|
||||
|
||||
@@ -117,6 +117,11 @@ function AlertDetails(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
// Show spinner until we have alert data loaded
|
||||
if (isLoading && !alertRuleDetails) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateAlertProvider
|
||||
ruleId={ruleId || ''}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtil
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { generatePath, useLocation, useParams } from 'react-router-dom';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -54,14 +53,12 @@ function DashboardWidget(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
<NewWidget
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
enableDrillDown={isDrilldownEnabled()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,9 @@ import ROUTES from 'constants/routes';
|
||||
import InfraMonitoringHosts from 'container/InfraMonitoringHosts';
|
||||
import InfraMonitoringK8s from 'container/InfraMonitoringK8s';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const Hosts: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<InfraMonitoringHosts />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <InfraMonitoringHosts />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Inbox size={16} /> Hosts
|
||||
@@ -21,11 +16,7 @@ export const Hosts: TabRoutes = {
|
||||
};
|
||||
|
||||
export const Kubernetes: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<InfraMonitoringK8s />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <InfraMonitoringK8s />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Inbox size={16} /> Kubernetes
|
||||
|
||||
@@ -3,7 +3,6 @@ import { liveLogsCompositeQuery } from 'container/LiveLogs/constants';
|
||||
import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useEffect } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -15,11 +14,7 @@ function LiveLogs(): JSX.Element {
|
||||
handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS);
|
||||
}, [handleSetConfig]);
|
||||
|
||||
return (
|
||||
<PreferenceContextProvider>
|
||||
<LiveLogsContainer />
|
||||
</PreferenceContextProvider>
|
||||
);
|
||||
return <LiveLogsContainer />;
|
||||
}
|
||||
|
||||
export default LiveLogs;
|
||||
|
||||
@@ -10,7 +10,6 @@ import LogsFilters from 'container/LogsFilters';
|
||||
import LogsSearchFilter from 'container/LogsSearchFilter';
|
||||
import LogsTable from 'container/LogsTable';
|
||||
import history from 'lib/history';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -83,71 +82,69 @@ function OldLogsExplorer(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferenceContextProvider>
|
||||
<div className="old-logs-explorer">
|
||||
<SpaceContainer
|
||||
split={<Divider type="vertical" />}
|
||||
align="center"
|
||||
direction="horizontal"
|
||||
>
|
||||
<LogsSearchFilter />
|
||||
<LogLiveTail />
|
||||
</SpaceContainer>
|
||||
<div className="old-logs-explorer">
|
||||
<SpaceContainer
|
||||
split={<Divider type="vertical" />}
|
||||
align="center"
|
||||
direction="horizontal"
|
||||
>
|
||||
<LogsSearchFilter />
|
||||
<LogLiveTail />
|
||||
</SpaceContainer>
|
||||
|
||||
<LogsAggregate />
|
||||
<LogsAggregate />
|
||||
|
||||
<Row gutter={20} wrap={false}>
|
||||
<LogsFilters />
|
||||
<Col flex={1} className="logs-col-container">
|
||||
<Row>
|
||||
<Col flex={1}>
|
||||
<Space align="baseline" direction="horizontal">
|
||||
<Select
|
||||
<Row gutter={20} wrap={false}>
|
||||
<LogsFilters />
|
||||
<Col flex={1} className="logs-col-container">
|
||||
<Row>
|
||||
<Col flex={1}>
|
||||
<Space align="baseline" direction="horizontal">
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
style={defaultSelectStyle}
|
||||
value={selectedViewModeOption}
|
||||
onChange={onChangeVeiwMode}
|
||||
>
|
||||
{viewModeOptionList.map((option) => (
|
||||
<Select.Option key={option.value}>{option.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{isFormatButtonVisible && (
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
style={defaultSelectStyle}
|
||||
value={selectedViewModeOption}
|
||||
onChange={onChangeVeiwMode}
|
||||
placement="right"
|
||||
content={renderPopoverContent}
|
||||
>
|
||||
{viewModeOptionList.map((option) => (
|
||||
<Select.Option key={option.value}>{option.label}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button>Format</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{isFormatButtonVisible && (
|
||||
<Popover
|
||||
getPopupContainer={popupContainer}
|
||||
placement="right"
|
||||
content={renderPopoverContent}
|
||||
>
|
||||
<Button>Format</Button>
|
||||
</Popover>
|
||||
)}
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
style={defaultSelectStyle}
|
||||
defaultValue={order}
|
||||
onChange={handleChangeOrder}
|
||||
>
|
||||
{orderItems.map((item) => (
|
||||
<Select.Option key={item.enum}>{item.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
style={defaultSelectStyle}
|
||||
defaultValue={order}
|
||||
onChange={handleChangeOrder}
|
||||
>
|
||||
{orderItems.map((item) => (
|
||||
<Select.Option key={item.enum}>{item.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<LogControls />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Col>
|
||||
<LogControls />
|
||||
</Col>
|
||||
</Row>
|
||||
<LogsTable viewMode={viewMode} linesPerRow={linesPerRow} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<LogsTable viewMode={viewMode} linesPerRow={linesPerRow} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<LogDetailedView />
|
||||
</div>
|
||||
</PreferenceContextProvider>
|
||||
<LogDetailedView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ function LogsExplorer(): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
const { preferences, loading: preferencesLoading } = usePreferenceContext();
|
||||
const { logs } = usePreferenceContext();
|
||||
const { preferences } = logs;
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -273,7 +274,7 @@ function LogsExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferences || preferencesLoading) {
|
||||
if (!preferences) {
|
||||
return;
|
||||
}
|
||||
const migratedQuery = migrateOptionsQuery({
|
||||
@@ -295,12 +296,7 @@ function LogsExplorer(): JSX.Element {
|
||||
) {
|
||||
redirectWithOptionsData(migratedQuery);
|
||||
}
|
||||
}, [
|
||||
migrateOptionsQuery,
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
preferencesLoading,
|
||||
]);
|
||||
}, [migrateOptionsQuery, preferences, redirectWithOptionsData]);
|
||||
|
||||
const toolbarViews = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -4,14 +4,9 @@ import { Compass, TowerControl, Workflow } from 'lucide-react';
|
||||
import LogsExplorer from 'pages/LogsExplorer';
|
||||
import Pipelines from 'pages/Pipelines';
|
||||
import SaveView from 'pages/SaveView';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const logsExplorer: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <LogsExplorer />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
@@ -22,11 +17,7 @@ export const logsExplorer: TabRoutes = {
|
||||
};
|
||||
|
||||
export const logsPipelines: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<Pipelines />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <Pipelines />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Workflow size={16} /> Pipelines
|
||||
|
||||
@@ -4,14 +4,9 @@ import BreakDownPage from 'container/MeterExplorer/Breakdown/BreakDown';
|
||||
import ExplorerPage from 'container/MeterExplorer/Explorer';
|
||||
import { Compass, TowerControl } from 'lucide-react';
|
||||
import SaveView from 'pages/SaveView';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const Explorer: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<ExplorerPage />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <ExplorerPage />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
|
||||
@@ -4,7 +4,6 @@ import ExplorerPage from 'container/MetricsExplorer/Explorer';
|
||||
import SummaryPage from 'container/MetricsExplorer/Summary';
|
||||
import { BarChart2, Compass, TowerControl } from 'lucide-react';
|
||||
import SaveView from 'pages/SaveView';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const Summary: TabRoutes = {
|
||||
Component: SummaryPage,
|
||||
@@ -18,11 +17,7 @@ export const Summary: TabRoutes = {
|
||||
};
|
||||
|
||||
export const Explorer: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<ExplorerPage />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <ExplorerPage />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
|
||||
@@ -4,7 +4,6 @@ import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import NewDashboard from 'container/NewDashboard';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorType } from 'types/common';
|
||||
|
||||
@@ -36,11 +35,7 @@ function DashboardPage(): JSX.Element {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferenceContextProvider>
|
||||
<NewDashboard />
|
||||
</PreferenceContextProvider>
|
||||
);
|
||||
return <NewDashboard />;
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
.old-trace-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.top-header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
|
||||
.new-cta-btn {
|
||||
display: flex;
|
||||
padding: 4px 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
box-shadow: none;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.old-trace-container {
|
||||
.top-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.new-cta-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import TraceDetail from '..';
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/resizable', () => ({
|
||||
ResizableHandle: jest.fn(),
|
||||
ResizablePanel: jest.fn(),
|
||||
ResizablePanelGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACE_DETAIL}`,
|
||||
search: '?spanId=28a8a67365d0bd8b&levelUp=0&levelDown=0',
|
||||
}),
|
||||
|
||||
useParams: jest.fn().mockReturnValue({
|
||||
id: '000000000000000071dc9b0a338729b4',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceFlameGraph/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>TraceFlameGraph</div>,
|
||||
}));
|
||||
|
||||
describe('TraceDetail', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render tracedetail', async () => {
|
||||
const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
// as we have an active spanId, it should scroll to the selected span
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
|
||||
// assertions
|
||||
expect(getByText('TraceFlameGraph')).toBeInTheDocument();
|
||||
expect(getByText('Focus on selected span')).toBeInTheDocument();
|
||||
|
||||
// span action buttons
|
||||
expect(getByText('Reset Focus')).toBeInTheDocument();
|
||||
expect(getByText('50 Spans')).toBeInTheDocument();
|
||||
|
||||
// trace span detail - parent -> child
|
||||
expect(getAllByText('frontend')[0]).toBeInTheDocument();
|
||||
expect(getByText('776.76 ms')).toBeInTheDocument();
|
||||
[
|
||||
{ trace: 'HTTP GET /dispatch', duration: '776.76 ms', count: '50' },
|
||||
{ trace: 'HTTP GET: /customer', duration: '349.44 ms', count: '4' },
|
||||
{
|
||||
trace: '/driver.DriverService/FindNearest',
|
||||
duration: '173.10 ms',
|
||||
count: '15',
|
||||
},
|
||||
// and so on ...
|
||||
].forEach((traceDetail) => {
|
||||
expect(getByText(traceDetail.trace)).toBeInTheDocument();
|
||||
expect(getByText(traceDetail.duration)).toBeInTheDocument();
|
||||
expect(getByText(traceDetail.count)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Details for selected Span
|
||||
expect(getByText('Details for selected Span')).toBeInTheDocument();
|
||||
['Service', 'Operation', 'SpanKind', 'StatusCodeString'].forEach((detail) => {
|
||||
expect(getByText(detail)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// go to related logs button
|
||||
const goToRelatedLogsButton = getByText('Go to Related logs');
|
||||
expect(goToRelatedLogsButton).toBeInTheDocument();
|
||||
|
||||
// Tag and Event tabs
|
||||
expect(getByText('Tags')).toBeInTheDocument();
|
||||
expect(getByText('Events')).toBeInTheDocument();
|
||||
expect(getByPlaceholderText('traceDetails:search_tags')).toBeInTheDocument();
|
||||
|
||||
// Tag details
|
||||
[
|
||||
{ title: 'client-uuid', value: '64a18ffd5f8adbfb' },
|
||||
{ title: 'component', value: 'net/http' },
|
||||
{ title: 'host.name', value: '4f6ec470feea' },
|
||||
{ title: 'http.method', value: 'GET' },
|
||||
{ title: 'http.url', value: '/route?dropoff=728%2C326&pickup=165%2C543' },
|
||||
{ title: 'http.status_code', value: '200' },
|
||||
{ title: 'ip', value: '172.25.0.2' },
|
||||
{ title: 'opencensus.exporterversion', value: 'Jaeger-Go-2.30.0' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// see full value
|
||||
expect(getAllByText('View full value')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tracedetail events tab', async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByText('Events'));
|
||||
|
||||
expect(await screen.findByText('HTTP request received')).toBeInTheDocument();
|
||||
|
||||
// event details
|
||||
[
|
||||
{ title: 'Event Start Time', value: '527.60 ms' },
|
||||
{ title: 'level', value: 'info' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(getByText('View full log event message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle slider - selected span details', async () => {
|
||||
const { findByTestId, queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const slider = await findByTestId('span-details-sider');
|
||||
expect(slider).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(slider.querySelector('.expand-collapse-btn') as HTMLElement);
|
||||
|
||||
expect(queryByText('Details for selected Span')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to selected another span and see its detail', async () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
const spanTitle = getByText('/driver.DriverService/FindNearest');
|
||||
expect(spanTitle).toBeInTheDocument();
|
||||
fireEvent.click(spanTitle);
|
||||
|
||||
// Tag details
|
||||
[
|
||||
{ title: 'client-uuid', value: '6fb81b8ca91b2b4d' },
|
||||
{ title: 'component', value: 'gRPC' },
|
||||
{ title: 'host.name', value: '4f6ec470feea' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('focus on selected span and reset focus action', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
const spanTitle = getByText('/driver.DriverService/FindNearest');
|
||||
expect(spanTitle).toBeInTheDocument();
|
||||
fireEvent.click(spanTitle);
|
||||
|
||||
expect(await screen.findByText('6fb81b8ca91b2b4d')).toBeInTheDocument();
|
||||
|
||||
// focus on selected span
|
||||
const focusButton = getByText('Focus on selected span');
|
||||
expect(focusButton).toBeInTheDocument();
|
||||
fireEvent.click(focusButton);
|
||||
|
||||
// assert selected span
|
||||
expect(getByText('15 Spans')).toBeInTheDocument();
|
||||
expect(getAllByText('/driver.DriverService/FindNearest')).toHaveLength(3);
|
||||
expect(getByText('173.10 ms')).toBeInTheDocument();
|
||||
|
||||
// reset focus
|
||||
expect(screen.queryByText('HTTP GET /dispatch')).not.toBeInTheDocument();
|
||||
|
||||
const resetFocusButton = getByText('Reset Focus');
|
||||
expect(resetFocusButton).toBeInTheDocument();
|
||||
fireEvent.click(resetFocusButton);
|
||||
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
expect(screen.queryByText('HTTP GET /dispatch')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export const SPAN_DETAILS_LEFT_COL_WIDTH = 350;
|
||||
|
||||
export const noEventMessage =
|
||||
'The requested trace id was not found. Sometimes this happens because of insertion delay in trace data. Please try again after some time';
|
||||
@@ -1,76 +0,0 @@
|
||||
import './TraceDetail.styles.scss';
|
||||
|
||||
import { Button, Typography } from 'antd';
|
||||
import getTraceItem from 'api/trace/getTraceItem';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TraceDetailContainer from 'container/TraceDetail';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Undo } from 'lucide-react';
|
||||
import TraceDetailsPage from 'pages/TraceDetailV2';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Props as TraceDetailProps } from 'types/api/trace/getTraceItem';
|
||||
|
||||
import { noEventMessage } from './constants';
|
||||
|
||||
function TraceDetail(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailProps>();
|
||||
const [showNewTraceDetails, setShowNewTraceDetails] = useState<boolean>(false);
|
||||
const urlQuery = useUrlQuery();
|
||||
const { spanId, levelUp, levelDown } = useMemo(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId'),
|
||||
levelUp: urlQuery.get('levelUp'),
|
||||
levelDown: urlQuery.get('levelDown'),
|
||||
}),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const { data: traceDetailResponse, error, isLoading, isError } = useQuery(
|
||||
`getTraceItem/${id}`,
|
||||
() => getTraceItem({ id, spanId, levelUp, levelDown }),
|
||||
{
|
||||
cacheTime: 3000,
|
||||
},
|
||||
);
|
||||
|
||||
if (showNewTraceDetails) {
|
||||
return <TraceDetailsPage />;
|
||||
}
|
||||
|
||||
if (traceDetailResponse?.error || error || isError) {
|
||||
return (
|
||||
<Typography>
|
||||
{traceDetailResponse?.error || 'Something went wrong'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !(traceDetailResponse && traceDetailResponse.payload)) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (traceDetailResponse.payload[0].events.length === 0) {
|
||||
return <NotFound text={noEventMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="old-trace-container">
|
||||
<div className="top-header">
|
||||
<Button
|
||||
onClick={(): void => setShowNewTraceDetails(true)}
|
||||
icon={<Undo size={14} />}
|
||||
type="text"
|
||||
className="new-cta-btn"
|
||||
>
|
||||
New Trace Detail
|
||||
</Button>
|
||||
</div>
|
||||
<TraceDetailContainer response={traceDetailResponse.payload} />;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetail;
|
||||
@@ -1,12 +1,10 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import { Tabs } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
|
||||
import TraceDetail from 'pages/TraceDetail';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
@@ -16,11 +14,10 @@ interface INewTraceDetailProps {
|
||||
key: string;
|
||||
children: JSX.Element;
|
||||
}[];
|
||||
handleOldTraceDetails: () => void;
|
||||
}
|
||||
|
||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
const { items, handleOldTraceDetails } = props;
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
<Tabs
|
||||
@@ -39,24 +36,12 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={handleOldTraceDetails}
|
||||
className="old-switch"
|
||||
icon={<Undo size={14} />}
|
||||
>
|
||||
Old Trace Details
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TraceDetailsPage(): JSX.Element {
|
||||
const [showOldTraceDetails, setShowOldTraceDetails] = useState<boolean>(false);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
@@ -86,13 +71,6 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
children: <div />,
|
||||
},
|
||||
];
|
||||
const handleOldTraceDetails = useCallback(() => {
|
||||
setShowOldTraceDetails(true);
|
||||
}, []);
|
||||
|
||||
return showOldTraceDetails ? (
|
||||
<TraceDetail />
|
||||
) : (
|
||||
<NewTraceDetail items={items} handleOldTraceDetails={handleOldTraceDetails} />
|
||||
);
|
||||
return <NewTraceDetail items={items} />;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOpti
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { ChevronDown, HardHat, PencilLine } from 'lucide-react';
|
||||
import { ChevronDown, PencilLine } from 'lucide-react';
|
||||
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -194,7 +194,6 @@ function FunnelStep({
|
||||
}
|
||||
hasPopupContainer={false}
|
||||
placeholder="Search for filters..."
|
||||
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
|
||||
rootClassName="traces-funnel-where-filter"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
:root {
|
||||
--bg-vanilla-100-rgb: 255, 255, 255;
|
||||
}
|
||||
.funnel-table {
|
||||
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0.01) 0%,
|
||||
rgba(171, 189, 255, 0.01) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
table {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0.01) 0%,
|
||||
rgba(171, 189, 255, 0.01) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
}
|
||||
|
||||
&__header {
|
||||
padding: 12px 14px 12px;
|
||||
@@ -97,9 +103,10 @@
|
||||
}
|
||||
|
||||
.table-row-dark {
|
||||
background: var(--bg-ink-300);
|
||||
background: rgba(var(--bg-vanilla-100-rgb), 0.01);
|
||||
}
|
||||
|
||||
|
||||
.trace-id-cell {
|
||||
color: var(--bg-robin-400);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -5,15 +5,10 @@ import SaveView from 'pages/SaveView';
|
||||
import TracesExplorer from 'pages/TracesExplorer';
|
||||
import TracesFunnelDetails from 'pages/TracesFunnelDetails';
|
||||
import TracesFunnels from 'pages/TracesFunnels';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
export const tracesExplorer: TabRoutes = {
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<TracesExplorer />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
Component: (): JSX.Element => <TracesExplorer />,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
|
||||
@@ -160,6 +160,8 @@
|
||||
min-width: 0;
|
||||
|
||||
.ant-select {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border: none;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -167,6 +169,10 @@
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
FormattingOptions,
|
||||
PreferenceMode,
|
||||
Preferences,
|
||||
} from 'providers/preferences/types';
|
||||
import { MemoryRouter, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
PreferenceContextProvider,
|
||||
usePreferenceContext,
|
||||
} from '../context/PreferenceContextProvider';
|
||||
|
||||
// Mock the usePreferenceSync hook
|
||||
jest.mock('../sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: jest.fn().mockReturnValue({
|
||||
preferences: {
|
||||
columns: [] as TelemetryFieldKey[],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
} as FormattingOptions,
|
||||
} as Preferences,
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test component that consumes the context
|
||||
function TestConsumer(): JSX.Element {
|
||||
const context = usePreferenceContext();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="mode">{context.mode}</div>
|
||||
<div data-testid="dataSource">{context.dataSource}</div>
|
||||
<div data-testid="loading">{String(context.loading)}</div>
|
||||
<div data-testid="error">{String(context.error)}</div>
|
||||
<div data-testid="savedViewId">{context.savedViewId || 'no-view-id'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PreferenceContextProvider', () => {
|
||||
it('should provide context with direct mode when no viewKey is present', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/logs"
|
||||
component={(): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<TestConsumer />
|
||||
</PreferenceContextProvider>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent(PreferenceMode.DIRECT);
|
||||
expect(screen.getByTestId('dataSource')).toHaveTextContent('logs');
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false');
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('null');
|
||||
expect(screen.getByTestId('savedViewId')).toHaveTextContent('no-view-id');
|
||||
});
|
||||
|
||||
it('should provide context with savedView mode when viewKey is present', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/logs?viewKey="test-view-id"']}>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/logs"
|
||||
component={(): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<TestConsumer />
|
||||
</PreferenceContextProvider>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent('savedView');
|
||||
expect(screen.getByTestId('dataSource')).toHaveTextContent('logs');
|
||||
expect(screen.getByTestId('savedViewId')).toHaveTextContent('test-view-id');
|
||||
});
|
||||
|
||||
it('should set traces dataSource when pathname includes traces', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/traces']}>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/traces"
|
||||
component={(): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<TestConsumer />
|
||||
</PreferenceContextProvider>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dataSource')).toHaveTextContent('traces');
|
||||
});
|
||||
|
||||
it('should handle invalid viewKey JSON gracefully', () => {
|
||||
// Mock console.error to avoid test output clutter
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/logs?viewKey=invalid-json']}>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/logs"
|
||||
component={(): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<TestConsumer />
|
||||
</PreferenceContextProvider>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent(PreferenceMode.DIRECT);
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should throw error when usePreferenceContext is used outside provider', () => {
|
||||
// Suppress the error output for this test
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
expect(() => {
|
||||
render(<TestConsumer />);
|
||||
}).toThrow(
|
||||
'usePreferenceContext must be used within PreferenceContextProvider',
|
||||
);
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
|
||||
const ROUTE_LOGS = '/logs';
|
||||
const ROUTE_TRACES = '/traces';
|
||||
const TESTID_LOGS = 'logs';
|
||||
const TESTID_TRACES = 'traces';
|
||||
|
||||
type LogsLocalOptions = {
|
||||
selectColumns?: TelemetryFieldKey[];
|
||||
maxLines?: number;
|
||||
format?: string;
|
||||
fontSize?: string;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
type TracesLocalOptions = {
|
||||
selectColumns?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
function setLocalStorageJSON(key: string, value: unknown): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function getLocalStorageJSON<T>(key: string): T | null {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
}
|
||||
|
||||
function Consumer({
|
||||
dataSource,
|
||||
testIdPrefix,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
testIdPrefix: string;
|
||||
}): JSX.Element {
|
||||
const ctx = usePreferenceContext();
|
||||
const slice = dataSource === DataSource.TRACES ? ctx.traces : ctx.logs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid={`${testIdPrefix}-loading`}>{String(slice.loading)}</div>
|
||||
<div data-testid={`${testIdPrefix}-columns-len`}>
|
||||
{String(slice.preferences?.columns?.length || 0)}
|
||||
</div>
|
||||
<button
|
||||
data-testid={`${testIdPrefix}-update-columns`}
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
const newCols: TelemetryFieldKey[] =
|
||||
dataSource === DataSource.TRACES
|
||||
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
slice.updateColumns(newCols);
|
||||
}}
|
||||
>
|
||||
update
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PreferencesProvider integration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Logs', () => {
|
||||
it('loads defaults when no localStorage or url provided', () => {
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('logs-loading')).toHaveTextContent('false');
|
||||
expect(
|
||||
Number(screen.getByTestId('logs-columns-len').textContent),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('respects localStorage when present', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.LOGS_LIST_OPTIONS, {
|
||||
selectColumns: [{ name: 'ls.col' }],
|
||||
maxLines: 5,
|
||||
format: 'json',
|
||||
fontSize: 'large',
|
||||
version: 2,
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
},
|
||||
);
|
||||
|
||||
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(1);
|
||||
});
|
||||
|
||||
it('direct mode updateColumns persists to localStorage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
},
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('logs-update-columns'));
|
||||
|
||||
const stored = getLocalStorageJSON<LogsLocalOptions>(
|
||||
LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored?.selectColumns).toEqual([
|
||||
defaultLogsSelectedColumns[0] as TelemetryFieldKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it('saved view mode uses in-memory preferences (no localStorage write)', async () => {
|
||||
const viewKey = JSON.stringify('saved-view-id-1');
|
||||
const initialEntry = `/logs?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix="logs" />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: initialEntry,
|
||||
},
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('logs-update-columns'));
|
||||
|
||||
const stored = getLocalStorageJSON<LogsLocalOptions>(
|
||||
LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored?.selectColumns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('url options override defaults', () => {
|
||||
const options = {
|
||||
selectColumns: [{ name: 'url.col' }],
|
||||
maxLines: 7,
|
||||
format: 'json',
|
||||
fontSize: 'large',
|
||||
version: 2,
|
||||
};
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
search: `?options=${encodeURIComponent(JSON.stringify(options))}`,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_LOGS,
|
||||
},
|
||||
);
|
||||
|
||||
// restore
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
|
||||
expect(Number(screen.getByTestId('logs-columns-len').textContent)).toBe(1);
|
||||
});
|
||||
|
||||
it('updateFormatting persists to localStorage in direct mode', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function FormattingConsumer(): JSX.Element {
|
||||
const { logs } = usePreferenceContext();
|
||||
return (
|
||||
<button
|
||||
data-testid="logs-update-formatting"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
logs.updateFormatting({
|
||||
maxLines: 9,
|
||||
format: 'json' as LogViewMode,
|
||||
fontSize: 'large' as FontSize,
|
||||
version: 2,
|
||||
})
|
||||
}
|
||||
>
|
||||
fmt
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
render(<FormattingConsumer />, undefined, { initialRoute: '/logs' });
|
||||
|
||||
await user.click(screen.getByTestId('logs-update-formatting'));
|
||||
|
||||
const stored = getLocalStorageJSON<LogsLocalOptions>(
|
||||
LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored?.maxLines).toBe(9);
|
||||
expect(stored?.format).toBe('json');
|
||||
expect(stored?.fontSize).toBe('large');
|
||||
expect(stored?.version).toBe(2);
|
||||
});
|
||||
|
||||
it('saved view mode updates in-memory preferences (columns-len changes)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const viewKey = JSON.stringify('saved-view-id-3');
|
||||
const initialEntry = `/logs?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
|
||||
undefined,
|
||||
{ initialRoute: initialEntry },
|
||||
);
|
||||
|
||||
const before = Number(screen.getByTestId('logs-columns-len').textContent);
|
||||
await user.click(screen.getByTestId('logs-update-columns'));
|
||||
const after = Number(screen.getByTestId('logs-columns-len').textContent);
|
||||
expect(after).toBeGreaterThanOrEqual(1);
|
||||
// Should change from default to 1 for our new selection; tolerate default already being >=1
|
||||
if (before !== after) {
|
||||
expect(after).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Traces', () => {
|
||||
it('loads defaults when no localStorage or url provided', () => {
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('traces-loading')).toHaveTextContent('false');
|
||||
expect(
|
||||
Number(screen.getByTestId('traces-columns-len').textContent),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('respects localStorage when present', () => {
|
||||
setLocalStorageJSON(LOCALSTORAGE.TRACES_LIST_OPTIONS, {
|
||||
selectColumns: [{ name: 'trace.ls.col' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
},
|
||||
);
|
||||
|
||||
expect(Number(screen.getByTestId('traces-columns-len').textContent)).toBe(1);
|
||||
});
|
||||
|
||||
it('direct mode updateColumns persists to localStorage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTE_TRACES,
|
||||
},
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('traces-update-columns'));
|
||||
|
||||
const stored = getLocalStorageJSON<TracesLocalOptions>(
|
||||
LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored?.selectColumns).toEqual([
|
||||
defaultTraceSelectedColumns[0] as TelemetryFieldKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it('saved view mode uses in-memory preferences (no localStorage write)', async () => {
|
||||
const viewKey = JSON.stringify('saved-view-id-2');
|
||||
const initialEntry = `/traces?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix="traces" />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: initialEntry,
|
||||
},
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('traces-update-columns'));
|
||||
|
||||
const stored = getLocalStorageJSON<TracesLocalOptions>(
|
||||
LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored?.selectColumns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('url options override defaults', () => {
|
||||
const options = {
|
||||
selectColumns: [{ name: 'trace.url.col' }],
|
||||
};
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
search: `?options=${encodeURIComponent(JSON.stringify(options))}`,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{ initialRoute: ROUTE_TRACES },
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
|
||||
expect(Number(screen.getByTestId('traces-columns-len').textContent)).toBe(1);
|
||||
});
|
||||
|
||||
it('updateFormatting is a no-op in direct mode (no localStorage write)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function TracesFormattingConsumer(): JSX.Element {
|
||||
const { traces } = usePreferenceContext();
|
||||
return (
|
||||
<button
|
||||
data-testid="traces-update-formatting"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
traces.updateFormatting({
|
||||
maxLines: 9,
|
||||
format: 'json' as LogViewMode,
|
||||
fontSize: 'large' as FontSize,
|
||||
version: 2,
|
||||
})
|
||||
}
|
||||
>
|
||||
fmt
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TracesFormattingConsumer />, undefined, { initialRoute: '/traces' });
|
||||
|
||||
await user.click(screen.getByTestId('traces-update-formatting'));
|
||||
|
||||
const stored = getLocalStorageJSON<TracesLocalOptions>(
|
||||
LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('saved view mode updates in-memory preferences (columns-len changes)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const viewKey = JSON.stringify('saved-view-id-4');
|
||||
const initialEntry = `/traces?viewKey=${encodeURIComponent(viewKey)}`;
|
||||
|
||||
render(
|
||||
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
|
||||
undefined,
|
||||
{ initialRoute: initialEntry },
|
||||
);
|
||||
|
||||
const before = Number(screen.getByTestId('traces-columns-len').textContent);
|
||||
await user.click(screen.getByTestId('traces-update-columns'));
|
||||
const after = Number(screen.getByTestId('traces-columns-len').textContent);
|
||||
expect(after).toBeGreaterThanOrEqual(1);
|
||||
if (before !== after) {
|
||||
expect(after).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,18 +11,18 @@ jest.mock('../configs/logsLoaderConfig', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
priority: ['local', 'url', 'default'],
|
||||
local: jest.fn().mockResolvedValue({
|
||||
local: jest.fn(() => ({
|
||||
columns: [{ name: 'local-column' }],
|
||||
formatting: { maxLines: 5, format: 'table', fontSize: 'medium', version: 1 },
|
||||
}),
|
||||
url: jest.fn().mockResolvedValue({
|
||||
})),
|
||||
url: jest.fn(() => ({
|
||||
columns: [{ name: 'url-column' }],
|
||||
formatting: { maxLines: 3, format: 'table', fontSize: 'small', version: 1 },
|
||||
}),
|
||||
default: jest.fn().mockResolvedValue({
|
||||
})),
|
||||
default: jest.fn(() => ({
|
||||
columns: [{ name: 'default-column' }],
|
||||
formatting: { maxLines: 2, format: 'table', fontSize: 'small', version: 1 },
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -30,15 +30,15 @@ jest.mock('../configs/tracesLoaderConfig', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
priority: ['local', 'url', 'default'],
|
||||
local: jest.fn().mockResolvedValue({
|
||||
local: jest.fn(() => ({
|
||||
columns: [{ name: 'local-trace-column' }],
|
||||
}),
|
||||
url: jest.fn().mockResolvedValue({
|
||||
})),
|
||||
url: jest.fn(() => ({
|
||||
columns: [{ name: 'url-trace-column' }],
|
||||
}),
|
||||
default: jest.fn().mockResolvedValue({
|
||||
})),
|
||||
default: jest.fn(() => ({
|
||||
columns: [{ name: 'default-trace-column' }],
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -57,11 +57,6 @@ describe('usePreferenceLoader', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Initially it should be loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.preferences).toBe(null);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
// Wait for the loader to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
@@ -123,30 +118,33 @@ describe('usePreferenceLoader', () => {
|
||||
});
|
||||
|
||||
it('should handle errors during loading', async () => {
|
||||
// Mock an error in the loader using jest.spyOn
|
||||
const localSpy = jest.spyOn(logsLoaderConfig, 'local');
|
||||
localSpy.mockRejectedValueOnce(new Error('Loading failed'));
|
||||
// Make first call succeed (initial state), second call throw in reSync effect
|
||||
const localSpy: jest.SpyInstance = jest.spyOn(logsLoaderConfig, 'local');
|
||||
localSpy.mockImplementationOnce(() => ({
|
||||
columns: [{ name: 'local-column' }],
|
||||
formatting: { maxLines: 5, format: 'table', fontSize: 'medium', version: 1 },
|
||||
}));
|
||||
localSpy.mockImplementationOnce(() => {
|
||||
throw new Error('Loading failed');
|
||||
});
|
||||
|
||||
const setReSync = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceLoader({
|
||||
dataSource: DataSource.LOGS,
|
||||
reSync: false,
|
||||
reSync: true,
|
||||
setReSync,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the loader to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('Loading failed');
|
||||
});
|
||||
|
||||
// Should have set the error
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('Loading failed');
|
||||
expect(result.current.preferences).toBe(null);
|
||||
// Reset reSync should be called
|
||||
expect(setReSync).toHaveBeenCalledWith(false);
|
||||
|
||||
// Restore original implementation
|
||||
localSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,10 @@ import { FormattingOptions } from '../types';
|
||||
|
||||
// --- LOGS preferences loader config ---
|
||||
const logsLoaders = {
|
||||
local: async (): Promise<{
|
||||
local: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
formatting: FormattingOptions;
|
||||
}> => {
|
||||
} => {
|
||||
const local = getLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS);
|
||||
if (local) {
|
||||
try {
|
||||
@@ -31,10 +31,10 @@ const logsLoaders = {
|
||||
}
|
||||
return { columns: [], formatting: undefined } as any;
|
||||
},
|
||||
url: async (): Promise<{
|
||||
url: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
formatting: FormattingOptions;
|
||||
}> => {
|
||||
} => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
@@ -50,10 +50,10 @@ const logsLoaders = {
|
||||
} catch {}
|
||||
return { columns: [], formatting: undefined } as any;
|
||||
},
|
||||
default: async (): Promise<{
|
||||
default: (): {
|
||||
columns: TelemetryFieldKey[];
|
||||
formatting: FormattingOptions;
|
||||
}> => ({
|
||||
} => ({
|
||||
columns: defaultLogsSelectedColumns,
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
|
||||
@@ -7,9 +7,9 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
|
||||
|
||||
// --- TRACES preferences loader config ---
|
||||
const tracesLoaders = {
|
||||
local: async (): Promise<{
|
||||
local: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
}> => {
|
||||
} => {
|
||||
const local = getLocalStorageKey(LOCALSTORAGE.TRACES_LIST_OPTIONS);
|
||||
if (local) {
|
||||
try {
|
||||
@@ -21,9 +21,9 @@ const tracesLoaders = {
|
||||
}
|
||||
return { columns: [] };
|
||||
},
|
||||
url: async (): Promise<{
|
||||
url: (): {
|
||||
columns: BaseAutocompleteData[];
|
||||
}> => {
|
||||
} => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
try {
|
||||
const options = JSON.parse(urlParams.get('options') || '{}');
|
||||
@@ -33,9 +33,9 @@ const tracesLoaders = {
|
||||
} catch {}
|
||||
return { columns: [] };
|
||||
},
|
||||
default: async (): Promise<{
|
||||
default: (): {
|
||||
columns: TelemetryFieldKey[];
|
||||
}> => ({
|
||||
} => ({
|
||||
columns: defaultTraceSelectedColumns,
|
||||
}),
|
||||
priority: ['local', 'url', 'default'] as const,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
PreferenceMode,
|
||||
} from 'providers/preferences/types';
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
@@ -18,7 +17,6 @@ export function PreferenceContextProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const location = useLocation();
|
||||
const params = useUrlQuery();
|
||||
|
||||
let savedViewId = '';
|
||||
@@ -30,41 +28,25 @@ export function PreferenceContextProvider({
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
let dataSource: DataSource = DataSource.LOGS;
|
||||
if (location.pathname.includes('traces')) dataSource = DataSource.TRACES;
|
||||
|
||||
const {
|
||||
preferences,
|
||||
loading,
|
||||
error,
|
||||
updateColumns,
|
||||
updateFormatting,
|
||||
} = usePreferenceSync({
|
||||
const logsSlice = usePreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
dataSource,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
|
||||
const tracesSlice = usePreferenceSync({
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
dataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
const value = useMemo<PreferenceContextValue>(
|
||||
() => ({
|
||||
preferences,
|
||||
loading,
|
||||
error,
|
||||
mode: savedViewId ? PreferenceMode.SAVED_VIEW : PreferenceMode.DIRECT,
|
||||
savedViewId: savedViewId || undefined,
|
||||
dataSource,
|
||||
updateColumns,
|
||||
updateFormatting,
|
||||
logs: logsSlice,
|
||||
traces: tracesSlice,
|
||||
}),
|
||||
[
|
||||
savedViewId,
|
||||
dataSource,
|
||||
preferences,
|
||||
loading,
|
||||
error,
|
||||
updateColumns,
|
||||
updateFormatting,
|
||||
],
|
||||
[logsSlice, tracesSlice],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,55 +17,48 @@ const migrateColumns = (columns: any): any =>
|
||||
return column;
|
||||
});
|
||||
|
||||
// Generic preferences loader that works with any config
|
||||
async function preferencesLoader<T>(config: {
|
||||
// Generic preferences loader that works with any config (synchronous version)
|
||||
function preferencesLoader<T>(config: {
|
||||
priority: readonly string[];
|
||||
[key: string]: any;
|
||||
}): Promise<T> {
|
||||
const findValidLoader = async (): Promise<T> => {
|
||||
// Try each loader in priority order
|
||||
const results = await Promise.all(
|
||||
config.priority.map(async (source) => ({
|
||||
source,
|
||||
result: await config[source](),
|
||||
})),
|
||||
);
|
||||
}): T {
|
||||
// Try each loader in priority order synchronously
|
||||
const results = config.priority.map((source: string) => ({
|
||||
source,
|
||||
result: config[source](),
|
||||
}));
|
||||
|
||||
// Find valid columns and formatting independently
|
||||
const validColumnsResult = results.find(
|
||||
({ result }) => result.columns?.length,
|
||||
);
|
||||
// Find valid columns and formatting independently
|
||||
const validColumnsResult = results.find(
|
||||
({ result }) => result.columns?.length,
|
||||
);
|
||||
const validFormattingResult = results.find(({ result }) => result.formatting);
|
||||
|
||||
const validFormattingResult = results.find(({ result }) => result.formatting);
|
||||
const migratedColumns = validColumnsResult?.result.columns
|
||||
? migrateColumns(validColumnsResult.result.columns)
|
||||
: undefined;
|
||||
|
||||
const migratedColumns = validColumnsResult?.result.columns
|
||||
? migrateColumns(validColumnsResult?.result.columns)
|
||||
: undefined;
|
||||
|
||||
// Combine valid results or fallback to default
|
||||
const finalResult = {
|
||||
columns: migratedColumns || config.default().columns,
|
||||
formatting:
|
||||
validFormattingResult?.result.formatting || config.default().formatting,
|
||||
};
|
||||
|
||||
return finalResult as T;
|
||||
// Combine valid results or fallback to default
|
||||
const finalResult = {
|
||||
columns: migratedColumns || config.default().columns,
|
||||
formatting:
|
||||
validFormattingResult?.result.formatting || config.default().formatting,
|
||||
};
|
||||
|
||||
return findValidLoader();
|
||||
return finalResult as T;
|
||||
}
|
||||
|
||||
// Use the generic loader with specific configs
|
||||
async function logsPreferencesLoader(): Promise<{
|
||||
function logsPreferencesLoader(): {
|
||||
columns: TelemetryFieldKey[];
|
||||
formatting: FormattingOptions;
|
||||
}> {
|
||||
} {
|
||||
return preferencesLoader(logsLoaderConfig);
|
||||
}
|
||||
|
||||
async function tracesPreferencesLoader(): Promise<{
|
||||
function tracesPreferencesLoader(): {
|
||||
columns: TelemetryFieldKey[];
|
||||
}> {
|
||||
} {
|
||||
return preferencesLoader(tracesLoaderConfig);
|
||||
}
|
||||
|
||||
@@ -82,29 +75,36 @@ export function usePreferenceLoader({
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [preferences, setPreferences] = useState<Preferences | null>(() => {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const { columns, formatting } = logsPreferencesLoader();
|
||||
return { columns, formatting };
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
const { columns } = tracesPreferencesLoader();
|
||||
return { columns };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
async function loadPreferences(): Promise<void> {
|
||||
setLoading(true);
|
||||
function loadPreferences(): void {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const { columns, formatting } = await logsPreferencesLoader();
|
||||
const { columns, formatting } = logsPreferencesLoader();
|
||||
setPreferences({ columns, formatting });
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
const { columns } = await tracesPreferencesLoader();
|
||||
const { columns } = tracesPreferencesLoader();
|
||||
setPreferences({ columns });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Reset reSync back to false after loading is complete
|
||||
if (reSync) {
|
||||
setReSync(false);
|
||||
@@ -113,10 +113,10 @@ export function usePreferenceLoader({
|
||||
}
|
||||
|
||||
// Only load preferences on initial mount or when reSync is true
|
||||
if (loading || reSync) {
|
||||
if (reSync) {
|
||||
loadPreferences();
|
||||
}
|
||||
}, [dataSource, reSync, setReSync, loading]);
|
||||
}, [dataSource, reSync, setReSync]);
|
||||
|
||||
return { preferences, loading, error };
|
||||
return { preferences, loading: false, error };
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ export function usePreferenceSync({
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { data: viewsData } = useGetAllViews(dataSource);
|
||||
const { data: viewsData } = useGetAllViews(
|
||||
dataSource,
|
||||
mode === PreferenceMode.SAVED_VIEW,
|
||||
);
|
||||
|
||||
const [
|
||||
savedViewPreferences,
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export enum PreferenceMode {
|
||||
SAVED_VIEW = 'savedView',
|
||||
DIRECT = 'direct',
|
||||
}
|
||||
|
||||
export interface PreferenceContextValue {
|
||||
export interface PreferenceSlice {
|
||||
preferences: Preferences | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
mode: PreferenceMode;
|
||||
savedViewId?: string;
|
||||
dataSource: DataSource;
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
}
|
||||
|
||||
export interface PreferenceContextValue {
|
||||
logs: PreferenceSlice;
|
||||
traces: PreferenceSlice;
|
||||
}
|
||||
|
||||
export interface FormattingOptions {
|
||||
maxLines?: number;
|
||||
format?: LogViewMode;
|
||||
|
||||
@@ -198,7 +198,7 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
|
||||
}
|
||||
|
||||
for _, route := range expressionRoutes {
|
||||
evaluateExpr, err := r.evaluateExpr(route.Expression, set)
|
||||
evaluateExpr, err := r.evaluateExpr(ctx, route.Expression, set)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -210,32 +210,71 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
|
||||
return matchedChannels, nil
|
||||
}
|
||||
|
||||
func (r *provider) evaluateExpr(expression string, labelSet model.LabelSet) (bool, error) {
|
||||
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
|
||||
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
|
||||
// the nested structure takes precedence. That means we will replace an existing leaf at any
|
||||
// intermediate path with a map so we can materialize the deeper structure.
|
||||
// TODO(srikanthccv): we need a better solution to handle this, remove the following
|
||||
// when we update the expr to support dotted keys
|
||||
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
|
||||
env := make(map[string]interface{})
|
||||
|
||||
for k, v := range labelSet {
|
||||
key := string(k)
|
||||
value := string(v)
|
||||
logForReview := false
|
||||
|
||||
for lk, lv := range labelSet {
|
||||
key := strings.TrimSpace(string(lk))
|
||||
value := string(lv)
|
||||
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
current := env
|
||||
|
||||
for i, part := range parts {
|
||||
if i == len(parts)-1 {
|
||||
current[part] = value
|
||||
} else {
|
||||
if current[part] == nil {
|
||||
current[part] = make(map[string]interface{})
|
||||
for i, raw := range parts {
|
||||
part := strings.TrimSpace(raw)
|
||||
|
||||
last := i == len(parts)-1
|
||||
if last {
|
||||
if _, isMap := current[part].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
// deeper structure already exists; do not overwrite.
|
||||
break
|
||||
}
|
||||
current = current[part].(map[string]interface{})
|
||||
current[part] = value
|
||||
break
|
||||
}
|
||||
|
||||
// ensure a map so we can keep descending.
|
||||
if nextMap, ok := current[part].(map[string]interface{}); ok {
|
||||
current = nextMap
|
||||
continue
|
||||
}
|
||||
|
||||
// if absent or a leaf, replace it with a map.
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
} else {
|
||||
env[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
// if a map already sits here (due to nested keys), keep the map (nested wins).
|
||||
if _, isMap := env[key].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
if logForReview {
|
||||
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", "labels", labelSet)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func (r *provider) evaluateExpr(ctx context.Context, expression string, labelSet model.LabelSet) (bool, error) {
|
||||
env := r.convertLabelSetToEnv(ctx, labelSet)
|
||||
|
||||
program, err := expr.Compile(expression, expr.Env(env))
|
||||
if err != nil {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "error compiling route policy %s: %v", expression, err)
|
||||
|
||||
@@ -278,7 +278,9 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProvider_EvaluateExpression(t *testing.T) {
|
||||
provider := &provider{}
|
||||
provider := &provider{
|
||||
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -646,7 +648,7 @@ func TestProvider_EvaluateExpression(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := provider.evaluateExpr(tt.expression, tt.labelSet)
|
||||
result, err := provider.evaluateExpr(context.Background(), tt.expression, tt.labelSet)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result, "Expression: %s", tt.expression)
|
||||
})
|
||||
@@ -907,3 +909,72 @@ func TestProvider_CreateRoutes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLabelSetToEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labelSet model.LabelSet
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "simple keys",
|
||||
labelSet: model.LabelSet{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested keys",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value1",
|
||||
"foo.baz": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value1",
|
||||
"baz": "value2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - nested structure wins",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar.baz": "deep",
|
||||
"foo.bar": "shallow",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{
|
||||
"baz": "deep",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - leaf value vs nested",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value",
|
||||
"foo": "should_be_ignored",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
|
||||
@@ -15,11 +16,14 @@ type AuthZ interface {
|
||||
Check(context.Context, *openfgav1.TupleKey) error
|
||||
|
||||
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
|
||||
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) error
|
||||
CheckWithTupleCreation(context.Context, authtypes.Claims, valuer.UUID, authtypes.Relation, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) error
|
||||
|
||||
// writes the tuples to upstream server
|
||||
Write(context.Context, *openfgav1.WriteRequest) error
|
||||
// Batch Check returns error when the upstream authorization server is unavailable or for all the tuples of subject (s) doesn't have relation (r) on object (o).
|
||||
BatchCheck(context.Context, []*openfgav1.TupleKey) error
|
||||
|
||||
// lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
|
||||
// Write accepts the insertion tuples and the deletion tuples.
|
||||
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
|
||||
|
||||
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
|
||||
ListObjects(context.Context, string, authtypes.Relation, authtypes.Typeable) ([]*authtypes.Object, error)
|
||||
}
|
||||
|
||||
@@ -232,13 +232,13 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
|
||||
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples, err := typeable.Tuples(subject, relation, selectors)
|
||||
tuples, err := authtypes.TypeableOrganization.Tuples(subject, translation, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, orgID.StringValue())}, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -251,11 +251,21 @@ func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims aut
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, req *openfgav1.WriteRequest) error {
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
deletionTuplesWithoutCondition := make([]*openfgav1.TupleKeyWithoutCondition, len(deletions))
|
||||
for idx, tuple := range deletions {
|
||||
deletionTuplesWithoutCondition[idx] = &openfgav1.TupleKeyWithoutCondition{User: tuple.User, Object: tuple.Object, Relation: tuple.Relation}
|
||||
}
|
||||
|
||||
_, err := provider.openfgaServer.Write(ctx, &openfgav1.WriteRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
Writes: req.Writes,
|
||||
Writes: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: additions,
|
||||
},
|
||||
Deletes: &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deletionTuplesWithoutCondition,
|
||||
},
|
||||
})
|
||||
|
||||
return err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
@@ -114,7 +115,19 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, tran
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID)})
|
||||
orgId, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
selectors, err := cb(req.Context(), claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, orgId, relation, translation, typeable, selectors)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -27,6 +27,7 @@ type Module interface {
|
||||
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
|
||||
|
||||
statsreporter.StatsCollector
|
||||
|
||||
role.RegisterTypeable
|
||||
}
|
||||
|
||||
|
||||
@@ -225,5 +225,5 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{dashboardtypes.ResourceDashboard, dashboardtypes.ResourcesDashboards}
|
||||
return []authtypes.Typeable{dashboardtypes.TypeableResourceDashboard, dashboardtypes.TypeableResourcesDashboards}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
@@ -47,6 +46,8 @@ func (module *module) GetResources(_ context.Context) []*authtypes.Resource {
|
||||
for _, register := range module.registry {
|
||||
typeables = append(typeables, register.MustGetTypeables()...)
|
||||
}
|
||||
// role module cannot self register itself!
|
||||
typeables = append(typeables, module.MustGetTypeables()...)
|
||||
|
||||
resources := make([]*authtypes.Resource, 0)
|
||||
for _, typeable := range typeables {
|
||||
@@ -142,24 +143,17 @@ func (module *module) Patch(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
}
|
||||
|
||||
func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
additionTuples, err := roletypes.GetAdditionTuples(id, relation, additions)
|
||||
additionTuples, err := roletypes.GetAdditionTuples(id, orgID, relation, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := roletypes.GetDeletionTuples(id, relation, deletions)
|
||||
deletionTuples, err := roletypes.GetDeletionTuples(id, orgID, relation, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.authz.Write(ctx, &openfgav1.WriteRequest{
|
||||
Writes: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: additionTuples,
|
||||
},
|
||||
Deletes: &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deletionTuples,
|
||||
},
|
||||
})
|
||||
err = module.authz.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,3 +164,7 @@ func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id va
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ type Module interface {
|
||||
|
||||
// Deletes the role metadata and tuples in authorization server
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
RegisterTypeable
|
||||
}
|
||||
|
||||
type RegisterTypeable interface {
|
||||
@@ -40,27 +42,19 @@ type RegisterTypeable interface {
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Creates the role metadata and tuples in authorization server
|
||||
Create(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Gets the role metadata
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Gets the objects for the given relation and role
|
||||
GetObjects(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Gets all the resources and the relations
|
||||
GetResources(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Lists all the roles metadata for the organization
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Patches the role metdata
|
||||
Patch(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Patches the objects for the given relation and role
|
||||
PatchObjects(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Deletes the role metadata and tuples in authorization server
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -1276,154 +1276,6 @@ func getLocalTableName(tableName string) string {
|
||||
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
hasCustomRetention, err := r.hasCustomRetentionColumn(ctx)
|
||||
if hasCustomRetention {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("SetTTLV2 only supported")}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing TTL")}
|
||||
}
|
||||
// uuid is used as transaction id
|
||||
uuidWithHyphen := uuid.New()
|
||||
uuid := strings.Replace(uuidWithHyphen.String(), "-", "", -1)
|
||||
|
||||
coldStorageDuration := -1
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
coldStorageDuration = int(params.ToColdStorageDuration)
|
||||
}
|
||||
|
||||
tableNameArray := []string{r.logsDB + "." + r.logsLocalTableV2, r.logsDB + "." + r.logsResourceLocalTableV2}
|
||||
|
||||
// check if there is existing things to be done
|
||||
for _, tableName := range tableNameArray {
|
||||
statusItem, err := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL query for logs_v2 table
|
||||
ttlLogsV2 := fmt.Sprintf(
|
||||
"ALTER TABLE %v ON CLUSTER %s MODIFY TTL toDateTime(timestamp / 1000000000) + "+
|
||||
"INTERVAL %v SECOND DELETE", tableNameArray[0], r.cluster, params.DelDuration)
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
ttlLogsV2 += fmt.Sprintf(", toDateTime(timestamp / 1000000000)"+
|
||||
" + INTERVAL %v SECOND TO VOLUME '%s'",
|
||||
params.ToColdStorageDuration, params.ColdStorageVolume)
|
||||
}
|
||||
|
||||
// TTL query for logs_v2_resource table
|
||||
// adding 1800 as our bucket size is 1800 seconds
|
||||
ttlLogsV2Resource := fmt.Sprintf(
|
||||
"ALTER TABLE %v ON CLUSTER %s MODIFY TTL toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + "+
|
||||
"INTERVAL %v SECOND DELETE", tableNameArray[1], r.cluster, params.DelDuration)
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
ttlLogsV2Resource += fmt.Sprintf(", toDateTime(seen_at_ts_bucket_start) + toIntervalSecond(1800) + "+
|
||||
"INTERVAL %v SECOND TO VOLUME '%s'",
|
||||
params.ToColdStorageDuration, params.ColdStorageVolume)
|
||||
}
|
||||
|
||||
ttlPayload := map[string]string{
|
||||
tableNameArray[0]: ttlLogsV2,
|
||||
tableNameArray[1]: ttlLogsV2Resource,
|
||||
}
|
||||
|
||||
// set the ttl if nothing is pending/ no errors
|
||||
go func(ttlPayload map[string]string) {
|
||||
for tableName, query := range ttlPayload {
|
||||
// https://github.com/SigNoz/signoz/issues/5470
|
||||
// we will change ttl for only the new parts and not the old ones
|
||||
query += " SETTINGS materialize_ttl_after_modify=0"
|
||||
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(&ttl).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("error in inserting to ttl_status table", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
|
||||
err := r.setColdStorage(context.Background(), tableName, params.ColdStorageVolume)
|
||||
if err != nil {
|
||||
zap.L().Error("error in setting cold storage", zap.Error(err))
|
||||
statusItem, err := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err == nil {
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
zap.L().Info("Executing TTL request: ", zap.String("request", query))
|
||||
statusItem, _ := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err := r.db.Exec(ctx, query); err != nil {
|
||||
zap.L().Error("error while setting ttl", zap.Error(err))
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_, dbErr = r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}(ttlPayload)
|
||||
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
// uuid is used as transaction id
|
||||
uuidWithHyphen := uuid.New()
|
||||
@@ -2043,6 +1895,19 @@ func (r *ClickHouseReader) validateTTLConditions(ctx context.Context, ttlConditi
|
||||
func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
// Keep only latest 100 transactions/requests
|
||||
r.deleteTtlTransactions(ctx, orgID, 100)
|
||||
|
||||
switch params.Type {
|
||||
case constants.TraceTTL:
|
||||
return r.setTTLTraces(ctx, orgID, params)
|
||||
case constants.MetricsTTL:
|
||||
return r.setTTLMetrics(ctx, orgID, params)
|
||||
default:
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error while setting ttl. ttl type should be <metrics|traces>, got %v", params.Type)}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
// uuid is used as transaction id
|
||||
uuidWithHyphen := uuid.New()
|
||||
uuid := strings.Replace(uuidWithHyphen.String(), "-", "", -1)
|
||||
@@ -2051,95 +1916,69 @@ func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *mod
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
coldStorageDuration = int(params.ToColdStorageDuration)
|
||||
}
|
||||
|
||||
switch params.Type {
|
||||
case constants.TraceTTL:
|
||||
return r.setTTLTraces(ctx, orgID, params)
|
||||
case constants.MetricsTTL:
|
||||
tableNames := []string{
|
||||
signozMetricDBName + "." + signozSampleLocalTableName,
|
||||
signozMetricDBName + "." + signozSamplesAgg5mLocalTableName,
|
||||
signozMetricDBName + "." + signozSamplesAgg30mLocalTableName,
|
||||
signozMetricDBName + "." + signozExpHistLocalTableName,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV4,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV46Hrs,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV41Day,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV41Week,
|
||||
tableNames := []string{
|
||||
signozMetricDBName + "." + signozSampleLocalTableName,
|
||||
signozMetricDBName + "." + signozSamplesAgg5mLocalTableName,
|
||||
signozMetricDBName + "." + signozSamplesAgg30mLocalTableName,
|
||||
signozMetricDBName + "." + signozExpHistLocalTableName,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV4,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV46Hrs,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV41Day,
|
||||
signozMetricDBName + "." + signozTSLocalTableNameV41Week,
|
||||
}
|
||||
for _, tableName := range tableNames {
|
||||
statusItem, err := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
for _, tableName := range tableNames {
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
metricTTL := func(tableName string) {
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(&ttl).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("error in inserting to ttl_status table", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
timeColumn := "timestamp_ms"
|
||||
if strings.Contains(tableName, "v4") || strings.Contains(tableName, "exp_hist") {
|
||||
timeColumn = "unix_milli"
|
||||
}
|
||||
|
||||
req := fmt.Sprintf(
|
||||
"ALTER TABLE %v ON CLUSTER %s MODIFY TTL toDateTime(toUInt32(%s / 1000), 'UTC') + "+
|
||||
"INTERVAL %v SECOND DELETE", tableName, r.cluster, timeColumn, params.DelDuration)
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
req += fmt.Sprintf(", toDateTime(toUInt32(%s / 1000), 'UTC')"+
|
||||
" + INTERVAL %v SECOND TO VOLUME '%s'",
|
||||
timeColumn, params.ToColdStorageDuration, params.ColdStorageVolume)
|
||||
}
|
||||
err := r.setColdStorage(context.Background(), tableName, params.ColdStorageVolume)
|
||||
if err != nil {
|
||||
zap.L().Error("Error in setting cold storage", zap.Error(err))
|
||||
statusItem, err := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
metricTTL := func(tableName string) {
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(&ttl).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("error in inserting to ttl_status table", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
timeColumn := "timestamp_ms"
|
||||
if strings.Contains(tableName, "v4") || strings.Contains(tableName, "exp_hist") {
|
||||
timeColumn = "unix_milli"
|
||||
}
|
||||
|
||||
req := fmt.Sprintf(
|
||||
"ALTER TABLE %v ON CLUSTER %s MODIFY TTL toDateTime(toUInt32(%s / 1000), 'UTC') + "+
|
||||
"INTERVAL %v SECOND DELETE", tableName, r.cluster, timeColumn, params.DelDuration)
|
||||
if len(params.ColdStorageVolume) > 0 {
|
||||
req += fmt.Sprintf(", toDateTime(toUInt32(%s / 1000), 'UTC')"+
|
||||
" + INTERVAL %v SECOND TO VOLUME '%s'",
|
||||
timeColumn, params.ToColdStorageDuration, params.ColdStorageVolume)
|
||||
}
|
||||
err := r.setColdStorage(context.Background(), tableName, params.ColdStorageVolume)
|
||||
if err != nil {
|
||||
zap.L().Error("Error in setting cold storage", zap.Error(err))
|
||||
statusItem, err := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err == nil {
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
req += " SETTINGS materialize_ttl_after_modify=0"
|
||||
zap.L().Info("Executing TTL request: ", zap.String("request", req))
|
||||
statusItem, _ := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err := r.db.Exec(ctx, req); err != nil {
|
||||
zap.L().Error("error while setting ttl.", zap.Error(err))
|
||||
if err == nil {
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
@@ -2153,32 +1992,46 @@ func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *mod
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_, dbErr = r.
|
||||
return
|
||||
}
|
||||
req += " SETTINGS materialize_ttl_after_modify=0"
|
||||
zap.L().Info("Executing TTL request: ", zap.String("request", req))
|
||||
statusItem, _ := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
if err := r.db.Exec(ctx, req); err != nil {
|
||||
zap.L().Error("error while setting ttl.", zap.Error(err))
|
||||
_, dbErr := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, tableName := range tableNames {
|
||||
go metricTTL(tableName)
|
||||
_, dbErr = r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
zap.L().Error("Error in processing ttl_status update sql query", zap.Error(dbErr))
|
||||
return
|
||||
}
|
||||
case constants.LogsTTL:
|
||||
return r.setTTLLogs(ctx, orgID, params)
|
||||
|
||||
default:
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error while setting ttl. ttl type should be <metrics|traces>, got %v", params.Type)}
|
||||
}
|
||||
|
||||
for _, tableName := range tableNames {
|
||||
go metricTTL(tableName)
|
||||
}
|
||||
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var SupportedFunctions = []string{
|
||||
"abs",
|
||||
"exp",
|
||||
"log",
|
||||
"ln",
|
||||
|
||||
@@ -197,10 +197,14 @@ func processResults(
|
||||
}, nil
|
||||
}
|
||||
|
||||
var SupportedFunctions = []string{"exp", "log", "ln", "exp2", "log2", "exp10", "log10", "sqrt", "cbrt", "erf", "erfc", "lgamma", "tgamma", "sin", "cos", "tan", "asin", "acos", "atan", "degrees", "radians", "now", "toUnixTimestamp"}
|
||||
var SupportedFunctions = []string{"abs", "exp", "log", "ln", "exp2", "log2", "exp10", "log10", "sqrt", "cbrt", "erf", "erfc", "lgamma", "tgamma", "sin", "cos", "tan", "asin", "acos", "atan", "degrees", "radians", "now", "toUnixTimestamp"}
|
||||
|
||||
func EvalFuncs() map[string]govaluate.ExpressionFunction {
|
||||
GoValuateFuncs := make(map[string]govaluate.ExpressionFunction)
|
||||
// Returns the absolute value of the given argument.
|
||||
GoValuateFuncs["abs"] = func(args ...interface{}) (interface{}, error) {
|
||||
return math.Abs(args[0].(float64)), nil
|
||||
}
|
||||
// Returns e to the power of the given argument.
|
||||
GoValuateFuncs["exp"] = func(args ...interface{}) (interface{}, error) {
|
||||
return math.Exp(args[0].(float64)), nil
|
||||
|
||||
@@ -94,7 +94,7 @@ func CollisionHandledFinalExpr(
|
||||
return "", nil, err
|
||||
}
|
||||
colName, _ = fm.FieldFor(ctx, key)
|
||||
colName, _ = telemetrytypes.DataTypeCollisionHandledFieldName(key, dummyValue, colName)
|
||||
colName, _ = DataTypeCollisionHandledFieldName(key, dummyValue, colName, qbtypes.FilterOperatorUnknown)
|
||||
stmts = append(stmts, colName)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func CollisionHandledFinalExpr(
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the body column")
|
||||
// colName, _ = jsonKeyToKey(context.Background(), field, qbtypes.FilterOperatorUnknown, dummyValue)
|
||||
} else {
|
||||
colName, _ = telemetrytypes.DataTypeCollisionHandledFieldName(field, dummyValue, colName)
|
||||
colName, _ = DataTypeCollisionHandledFieldName(field, dummyValue, colName, qbtypes.FilterOperatorUnknown)
|
||||
}
|
||||
|
||||
stmts = append(stmts, colName)
|
||||
@@ -194,3 +194,109 @@ func FormatFullTextSearch(input string) string {
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, value any, tblFieldName string, operator qbtypes.FilterOperator) (string, any) {
|
||||
// This block of code exists to handle the data type collisions
|
||||
// We don't want to fail the requests when there is a key with more than one data type
|
||||
// Let's take an example of `http.status_code`, and consider user sent a string value and number value
|
||||
// When they search for `http.status_code=200`, we will search across both the number columns and string columns
|
||||
// and return the results from both the columns
|
||||
// While we expect user not to send the mixed data types, it inevitably happens
|
||||
// So we handle the data type collisions here
|
||||
switch key.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
// try to convert the string value to to number
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
case []any:
|
||||
if allFloats(v) {
|
||||
tblFieldName = castFloat(tblFieldName)
|
||||
} else if hasString(v) {
|
||||
_, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
case bool:
|
||||
// we don't have a toBoolOrNull in ClickHouse, so we need to convert the bool to a string
|
||||
value = fmt.Sprintf("%t", v)
|
||||
}
|
||||
|
||||
case telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeNumber:
|
||||
switch v := value.(type) {
|
||||
// why? ; CH returns an error for a simple check
|
||||
// attributes_number['http.status_code'] = 200 but not for attributes_number['http.status_code'] >= 200
|
||||
// DB::Exception: Bad get: has UInt64, requested Float64.
|
||||
// How is it working in v4? v4 prepares the full query with values in query string
|
||||
// When we format the float it becomes attributes_number['http.status_code'] = 200.000
|
||||
// Which CH gladly accepts and doesn't throw error
|
||||
// However, when passed as query args, the default formatter
|
||||
// https://github.com/ClickHouse/clickhouse-go/blob/757e102f6d8c6059d564ce98795b4ce2a101b1a5/bind.go#L393
|
||||
// is used which prepares the
|
||||
// final query as attributes_number['http.status_code'] = 200 giving this error
|
||||
// This following is one way to workaround it
|
||||
|
||||
// if the key is a number, the value is a string, we will let clickHouse handle the conversion
|
||||
case float32, float64:
|
||||
tblFieldName = castFloatHack(tblFieldName)
|
||||
case string:
|
||||
// check if it's a number inside a string
|
||||
isNumber := false
|
||||
if _, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
isNumber = true
|
||||
}
|
||||
|
||||
if !operator.IsComparisonOperator() || !isNumber {
|
||||
// try to convert the number attribute to string
|
||||
tblFieldName = castString(tblFieldName) // numeric col vs string literal
|
||||
} else {
|
||||
tblFieldName = castFloatHack(tblFieldName)
|
||||
}
|
||||
case []any:
|
||||
if allFloats(v) {
|
||||
tblFieldName = castFloatHack(tblFieldName)
|
||||
} else if hasString(v) {
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
|
||||
case telemetrytypes.FieldDataTypeBool:
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
tblFieldName = castString(tblFieldName)
|
||||
case []any:
|
||||
if hasString(v) {
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tblFieldName, value
|
||||
}
|
||||
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
|
||||
func allFloats(in []any) bool {
|
||||
for _, x := range in {
|
||||
if _, ok := x.(float64); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasString(in []any) bool {
|
||||
for _, x := range in {
|
||||
if _, ok := x.(string); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toStrings(in []any) []any {
|
||||
out := make([]any, len(in))
|
||||
for i, x := range in {
|
||||
out[i] = fmt.Sprintf("%v", x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
165
pkg/querybuilder/fallback_expr_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDataTypeCollisionHandledFieldName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key *telemetrytypes.TelemetryFieldKey
|
||||
value any
|
||||
tblFieldName string
|
||||
expectedFieldName string
|
||||
expectedValue any
|
||||
operator qbtypes.FilterOperator
|
||||
}{
|
||||
{
|
||||
name: "http_status_code_string_field_with_numeric_value",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.status_code",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
value: float64(200),
|
||||
tblFieldName: "attribute_string_http$$status_code",
|
||||
expectedFieldName: "toFloat64OrNull(attribute_string_http$$status_code)",
|
||||
expectedValue: float64(200),
|
||||
},
|
||||
{
|
||||
name: "service_enabled_string_field_with_bool_value",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.enabled",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
value: true,
|
||||
tblFieldName: "attribute_string_service$$enabled",
|
||||
expectedFieldName: "attribute_string_service$$enabled",
|
||||
expectedValue: "true",
|
||||
},
|
||||
{
|
||||
name: "http_method_string_field_with_string_value",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
value: "GET",
|
||||
tblFieldName: "attribute_string_http$$method",
|
||||
expectedFieldName: "attribute_string_http$$method",
|
||||
expectedValue: "GET",
|
||||
},
|
||||
{
|
||||
name: "response_times_string_field_with_numeric_array",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "response.times",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
value: []any{float64(100.5), float64(200.3), float64(150.7)},
|
||||
tblFieldName: "attribute_string_response$$times",
|
||||
expectedFieldName: "toFloat64OrNull(attribute_string_response$$times)",
|
||||
expectedValue: []any{float64(100.5), float64(200.3), float64(150.7)},
|
||||
},
|
||||
{
|
||||
name: "error_codes_string_field_with_mixed_array",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "error.codes",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
value: []any{float64(500), "TIMEOUT", float64(503)},
|
||||
tblFieldName: "attribute_string_error$$codes",
|
||||
expectedFieldName: "attribute_string_error$$codes",
|
||||
expectedValue: []any{"500", "TIMEOUT", "503"},
|
||||
},
|
||||
|
||||
// numbers
|
||||
{
|
||||
// we cast the key to string if the value is not a number or operator is not a comparison operator
|
||||
name: "http_request_duration_float_field_with_string_value",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.request.duration",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
},
|
||||
value: "1234.56",
|
||||
tblFieldName: "attribute_float64_http$$request$$duration",
|
||||
expectedFieldName: "toString(attribute_float64_http$$request$$duration)",
|
||||
expectedValue: "1234.56",
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
},
|
||||
{
|
||||
// we cast to float64 if it's a comparison operator and the value is a stringified number
|
||||
// reason:- https://github.com/SigNoz/signoz/pull/9154#issuecomment-3369941207
|
||||
name: "http_request_duration_float_field_with_string_value_comparison_operator",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.request.duration",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
},
|
||||
value: "9",
|
||||
tblFieldName: "attribute_float64_http$$request$$duration",
|
||||
expectedFieldName: "toFloat64(attribute_float64_http$$request$$duration)",
|
||||
expectedValue: "9",
|
||||
operator: qbtypes.FilterOperatorGreaterThan,
|
||||
},
|
||||
{
|
||||
// we cast to float64 if it's a comparison operator and the value is a stringified number
|
||||
// reason:- https://github.com/SigNoz/signoz/pull/9154#issuecomment-3369941207
|
||||
name: "http_request_duration_float_field_with_string_value_comparison_operator_1",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.request.duration",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
},
|
||||
value: "9.11",
|
||||
tblFieldName: "attribute_float64_http$$request$$duration",
|
||||
expectedFieldName: "toFloat64(attribute_float64_http$$request$$duration)",
|
||||
expectedValue: "9.11",
|
||||
operator: qbtypes.FilterOperatorGreaterThan,
|
||||
},
|
||||
{
|
||||
// we cast the key to string if the value is not a number or operator is not a comparison operator
|
||||
name: "http_request_duration_float_field_with_string_value_comparison_operator_2",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.request.duration",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
},
|
||||
value: "ERROR",
|
||||
tblFieldName: "attribute_float64_http$$request$$duration",
|
||||
expectedFieldName: "toString(attribute_float64_http$$request$$duration)",
|
||||
expectedValue: "ERROR",
|
||||
operator: qbtypes.FilterOperatorGreaterThan,
|
||||
},
|
||||
|
||||
// bools
|
||||
{
|
||||
name: "feature_enabled_bool_field_with_string_value",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "feature.enabled",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
value: "true",
|
||||
tblFieldName: "attribute_bool_feature$$enabled",
|
||||
expectedFieldName: "toString(attribute_bool_feature$$enabled)",
|
||||
expectedValue: "true",
|
||||
},
|
||||
{
|
||||
name: "feature_flags_bool_field_with_mixed_array",
|
||||
key: &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "feature.flags",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
value: []any{true, "enabled", false},
|
||||
tblFieldName: "attribute_bool_feature$$flags",
|
||||
expectedFieldName: "toString(attribute_bool_feature$$flags)",
|
||||
expectedValue: []any{"true", "enabled", "false"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resultFieldName, resultValue := DataTypeCollisionHandledFieldName(tt.key, tt.value, tt.tblFieldName, tt.operator)
|
||||
assert.Equal(t, tt.expectedFieldName, resultFieldName)
|
||||
assert.Equal(t, tt.expectedValue, resultValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
tblFieldName, value = GetBodyJSONKey(ctx, key, operator, value)
|
||||
}
|
||||
|
||||
tblFieldName, value = telemetrytypes.DataTypeCollisionHandledFieldName(key, value, tblFieldName)
|
||||
tblFieldName, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator)
|
||||
|
||||
// make use of case insensitive index for body
|
||||
if tblFieldName == "body" {
|
||||
|
||||
@@ -424,6 +424,16 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
expectedArgs: []any{"critical", "resolved", "open", true},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
// this will result in failure from the DB side.
|
||||
// user will have to use attribute.status:string > open
|
||||
category: "FREETEXT with conditions",
|
||||
query: "critical NOT resolved status > open",
|
||||
shouldPass: true,
|
||||
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND NOT (match(LOWER(body), LOWER(?))) AND (toString(attributes_number['status']) > ? AND mapContains(attributes_number, 'status') = ?))",
|
||||
expectedArgs: []any{"critical", "resolved", "open", true},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "FREETEXT with conditions",
|
||||
query: "database error type=mysql",
|
||||
|
||||