Compare commits
2 Commits
enh/dashbo
...
tvats-hand
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509a1cfb85 | ||
|
|
fd118d386a |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.11
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
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.11
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
10
Makefile
10
Makefile
@@ -84,9 +84,10 @@ go-run-enterprise: ## Runs the enterprise go backend server
|
||||
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
|
||||
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
|
||||
go run -race \
|
||||
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go
|
||||
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go \
|
||||
--config ./conf/prometheus.yml \
|
||||
--cluster cluster
|
||||
|
||||
.PHONY: go-test
|
||||
go-test: ## Runs go unit tests
|
||||
@@ -101,9 +102,10 @@ go-run-community: ## Runs the community go backend server
|
||||
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
|
||||
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
|
||||
go run -race \
|
||||
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
|
||||
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server \
|
||||
--config ./conf/prometheus.yml \
|
||||
--cluster cluster
|
||||
|
||||
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
|
||||
go-build-community: ## Builds the go backend server for community
|
||||
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
|
||||
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
@@ -79,9 +76,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
|
||||
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
@@ -19,7 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -108,9 +105,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return authNs, nil
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.102.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
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.11
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
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.11
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
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.102.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
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.11
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
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.11
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
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.102.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
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.11}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
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.11}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
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.11}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
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.102.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
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.11}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
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.11}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
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.11}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -103,19 +103,9 @@ Remember to replace the region and ingestion key with proper values as obtained
|
||||
|
||||
Both SigNoz and OTel demo app [frontend-proxy service, to be accurate] share common port allocation at 8080. To prevent port allocation conflicts, modify the OTel demo application config to use port 8081 as the `ENVOY_PORT` value as shown below, and run docker compose command.
|
||||
|
||||
Also, both SigNoz and OTel Demo App have the same `PROMETHEUS_PORT` configured, by default both of them try to start at `9090`, which may cause either of them to fail depending upon which one acquires it first. To prevent this, we need to mofify the value of `PROMETHEUS_PORT` too.
|
||||
|
||||
|
||||
```sh
|
||||
ENVOY_PORT=8081 PROMETHEUS_PORT=9091 docker compose up -d
|
||||
ENVOY_PORT=8081 docker compose up -d
|
||||
```
|
||||
|
||||
Alternatively, we can modify these values using the `.env` file too, which reduces the command as just:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This spins up multiple microservices, with OpenTelemetry instrumentation enabled. you can verify this by,
|
||||
```sh
|
||||
docker compose ps -a
|
||||
|
||||
@@ -48,26 +48,7 @@ func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey)
|
||||
}
|
||||
|
||||
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.TypeableUser, claims.UserID, orgID, nil)
|
||||
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) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,18 +15,18 @@ type anonymous
|
||||
|
||||
type role
|
||||
relations
|
||||
define assignee: [user, anonymous]
|
||||
define assignee: [user]
|
||||
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
|
||||
type metaresources
|
||||
type resources
|
||||
relations
|
||||
define create: [user, role#assignee]
|
||||
define list: [user, role#assignee]
|
||||
|
||||
type metaresource
|
||||
type resource
|
||||
relations
|
||||
define read: [user, anonymous, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
@@ -35,6 +35,6 @@ type metaresource
|
||||
define block: [user, role#assignee]
|
||||
|
||||
|
||||
type telemetryresource
|
||||
type telemetry
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define read: [user, anonymous, role#assignee]
|
||||
|
||||
@@ -20,10 +20,6 @@ import (
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -103,39 +99,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
|
||||
|
||||
// dashboards
|
||||
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.CreatePublic)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.GetPublic)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.UpdatePublic)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.DeletePublic)).Methods(http.MethodDelete)
|
||||
|
||||
// public access for dashboards
|
||||
router.HandleFunc("/api/v1/public/dashboards/{id}", am.CheckWithoutClaims(
|
||||
ah.Signoz.Handlers.Dashboard.GetPublicData,
|
||||
authtypes.RelationRead, authtypes.RelationRead,
|
||||
dashboardtypes.TypeableMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
|
||||
})).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/public/dashboards/{id}/widgets/{index}/query_range", am.CheckWithoutClaims(
|
||||
ah.Signoz.Handlers.Dashboard.GetPublicWidgetQueryRange,
|
||||
authtypes.RelationRead, authtypes.RelationRead,
|
||||
dashboardtypes.TypeableMetaResourcePublicDashboard,
|
||||
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
|
||||
})).Methods(http.MethodGet)
|
||||
|
||||
// v3
|
||||
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
|
||||
|
||||
@@ -192,7 +192,7 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
|
||||
|
||||
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
|
||||
r := baseapp.NewRouter()
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
|
||||
|
||||
r.Use(otelmux.Middleware(
|
||||
"apiserver",
|
||||
|
||||
@@ -280,7 +280,6 @@
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4"
|
||||
"on-headers": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
@@ -274,7 +274,7 @@ function App(): JSX.Element {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
name: user.displayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LogEventAxiosInstance as axios } from 'api';
|
||||
import { ApiBaseInstance as axios } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
|
||||
import { getFieldKeys } from '../getFieldKeys';
|
||||
|
||||
// Mock the API instance
|
||||
jest.mock('api', () => ({
|
||||
ApiBaseInstance: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('getFieldKeys API', () => {
|
||||
@@ -29,33 +31,33 @@ describe('getFieldKeys API', () => {
|
||||
|
||||
it('should call API with correct parameters when no args provided', async () => {
|
||||
// Mock successful API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
|
||||
// Call function with no parameters
|
||||
await getFieldKeys();
|
||||
|
||||
// Verify API was called correctly with empty params object
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API with signal parameter when provided', async () => {
|
||||
// Mock successful API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
|
||||
// Call function with signal parameter
|
||||
await getFieldKeys('traces');
|
||||
|
||||
// Verify API was called with signal parameter
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
params: { signal: 'traces' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API with name parameter when provided', async () => {
|
||||
// Mock successful API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -70,14 +72,14 @@ describe('getFieldKeys API', () => {
|
||||
await getFieldKeys(undefined, 'service');
|
||||
|
||||
// Verify API was called with name parameter
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
params: { name: 'service' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call API with both signal and name when provided', async () => {
|
||||
// Mock successful API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -92,14 +94,14 @@ describe('getFieldKeys API', () => {
|
||||
await getFieldKeys('logs', 'service');
|
||||
|
||||
// Verify API was called with both parameters
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
||||
params: { signal: 'logs', name: 'service' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return properly formatted response', async () => {
|
||||
// Mock API to return our response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||
|
||||
// Call the function
|
||||
const result = await getFieldKeys('traces');
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
|
||||
import { getFieldValues } from '../getFieldValues';
|
||||
|
||||
// Mock the API instance
|
||||
jest.mock('api', () => ({
|
||||
ApiBaseInstance: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('getFieldValues API', () => {
|
||||
@@ -15,7 +17,7 @@ describe('getFieldValues API', () => {
|
||||
|
||||
it('should call the API with correct parameters (no options)', async () => {
|
||||
// Mock API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -32,14 +34,14 @@ describe('getFieldValues API', () => {
|
||||
await getFieldValues();
|
||||
|
||||
// Verify API was called correctly with empty params
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with signal parameter', async () => {
|
||||
// Mock API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -56,14 +58,14 @@ describe('getFieldValues API', () => {
|
||||
await getFieldValues('traces');
|
||||
|
||||
// Verify API was called with signal parameter
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||
params: { signal: 'traces' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with name parameter', async () => {
|
||||
// Mock API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -80,14 +82,14 @@ describe('getFieldValues API', () => {
|
||||
await getFieldValues(undefined, 'service.name');
|
||||
|
||||
// Verify API was called with name parameter
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||
params: { name: 'service.name' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with value parameter', async () => {
|
||||
// Mock API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -104,14 +106,14 @@ describe('getFieldValues API', () => {
|
||||
await getFieldValues(undefined, 'service.name', 'front');
|
||||
|
||||
// Verify API was called with value parameter
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||
params: { name: 'service.name', searchText: 'front' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with time range parameters', async () => {
|
||||
// Mock API response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
status: 'success',
|
||||
@@ -136,7 +138,7 @@ describe('getFieldValues API', () => {
|
||||
);
|
||||
|
||||
// Verify API was called with time range parameters (converted to milliseconds)
|
||||
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
||||
params: {
|
||||
signal: 'logs',
|
||||
name: 'service.name',
|
||||
@@ -163,7 +165,7 @@ describe('getFieldValues API', () => {
|
||||
},
|
||||
};
|
||||
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
// Call the function
|
||||
const result = await getFieldValues('traces', 'mixed.values');
|
||||
@@ -194,7 +196,7 @@ describe('getFieldValues API', () => {
|
||||
};
|
||||
|
||||
// Mock API to return our response
|
||||
(axios.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
|
||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
|
||||
|
||||
// Call the function
|
||||
const result = await getFieldValues('traces', 'service.name');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
@@ -24,7 +24,7 @@ export const getFieldKeys = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/fields/keys', { params });
|
||||
const response = await ApiBaseInstance.get('/fields/keys', { params });
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
@@ -47,7 +47,7 @@ export const getFieldValues = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/fields/values', { params });
|
||||
const response = await ApiBaseInstance.get('/fields/values', { params });
|
||||
|
||||
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||
if (response.data?.data?.values) {
|
||||
|
||||
@@ -86,9 +86,8 @@ const interceptorRejected = async (
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
// if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
response.config.url !== '/sessions/email_password' &&
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
)
|
||||
@@ -200,15 +199,15 @@ ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
export const LogEventAxiosInstance = axios.create({
|
||||
export const ApiBaseInstance = axios.create({
|
||||
baseURL: `${ENVIRONMENT.baseURL}${apiV1}`,
|
||||
});
|
||||
|
||||
LogEventAxiosInstance.interceptors.response.use(
|
||||
ApiBaseInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
interceptorRejectedBase,
|
||||
);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
//
|
||||
|
||||
// gateway Api V1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
|
||||
@@ -17,7 +17,7 @@ export const getHostAttributeKeys = async (
|
||||
try {
|
||||
const response: AxiosResponse<{
|
||||
data: IQueryAutocompleteResponse;
|
||||
}> = await axios.get(
|
||||
}> = await ApiBaseInstance.get(
|
||||
`/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`,
|
||||
{
|
||||
params: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -20,7 +20,7 @@ const getOnboardingStatus = async (props: {
|
||||
}): Promise<SuccessResponse<OnboardingStatusResponse> | ErrorResponse> => {
|
||||
const { endpointService, ...rest } = props;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
const response = await ApiBaseInstance.post(
|
||||
`/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`,
|
||||
rest,
|
||||
);
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { ApiV2Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import axios from 'api';
|
||||
import { PayloadProps, Props } from 'types/api/metrics/getService';
|
||||
|
||||
const getService = async (props: Props): Promise<PayloadProps> => {
|
||||
try {
|
||||
const response = await ApiV2Instance.post(`/services`, {
|
||||
const response = await axios.post(`/services`, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
tags: props.selectedTags,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default getService;
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import { ApiV2Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import axios from 'api';
|
||||
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
|
||||
|
||||
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
|
||||
try {
|
||||
const endpoint = props.isEntryPoint
|
||||
? '/service/entry_point_operations'
|
||||
: '/service/top_operations';
|
||||
|
||||
const response = await ApiV2Instance.post(endpoint, {
|
||||
const response = await axios.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
|
||||
if (props.isEntryPoint) {
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export default getTopOperations;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
@@ -9,7 +9,7 @@ const getCustomFilters = async (
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
const response = await ApiBaseInstance.get(`orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
@@ -6,7 +6,7 @@ import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFil
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
ApiBaseInstance.put(`orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
@@ -9,12 +9,15 @@ const listOverview = async (
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
const { start, end, show_ip: showIp, filter } = props;
|
||||
try {
|
||||
const response = await axios.post(`/third-party-apis/overview/list`, {
|
||||
const response = await ApiBaseInstance.post(
|
||||
`/third-party-apis/overview/list`,
|
||||
{
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'api';
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
@@ -11,7 +11,7 @@ const getSpanPercentiles = async (
|
||||
props: GetSpanPercentilesProps,
|
||||
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
|
||||
try {
|
||||
const response = await axios.post('/span_percentile', {
|
||||
const response = await ApiBaseInstance.post('/span_percentile', {
|
||||
...props,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
@@ -85,11 +89,15 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
displayName: 'Body',
|
||||
key: LOG_FIELD_BODY_KEY,
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
displayName: 'Timestamp',
|
||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,10 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
// utils
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// interfaces
|
||||
@@ -42,7 +46,9 @@ interface LogFieldProps {
|
||||
}
|
||||
|
||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||
Pick<AddToQueryHOCProps, 'onAddToQuery'> & {
|
||||
fieldKeyDisplay: string;
|
||||
};
|
||||
|
||||
function LogGeneralField({
|
||||
fieldKey,
|
||||
@@ -74,6 +80,7 @@ function LogGeneralField({
|
||||
function LogSelectedField({
|
||||
fieldKey = '',
|
||||
fieldValue = '',
|
||||
fieldKeyDisplay = '',
|
||||
onAddToQuery,
|
||||
fontSize,
|
||||
}: LogSelectedFieldProps): JSX.Element {
|
||||
@@ -90,7 +97,7 @@ function LogSelectedField({
|
||||
style={{ color: blue[4] }}
|
||||
className={cx('selected-log-field-key', fontSize)}
|
||||
>
|
||||
{fieldKey}
|
||||
{fieldKeyDisplay}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</AddToQueryHOC>
|
||||
@@ -162,7 +169,7 @@ function ListLogView({
|
||||
);
|
||||
|
||||
const updatedSelecedFields = useMemo(
|
||||
() => selectedFields.filter((e) => e.name !== 'id'),
|
||||
() => selectedFields.filter((e) => e.key !== 'id'),
|
||||
[selectedFields],
|
||||
);
|
||||
|
||||
@@ -170,16 +177,16 @@ function ListLogView({
|
||||
|
||||
const timestampValue = useMemo(
|
||||
() =>
|
||||
typeof flattenLogData.timestamp === 'string'
|
||||
typeof flattenLogData[LOG_FIELD_TIMESTAMP_KEY] === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
flattenLogData.timestamp,
|
||||
flattenLogData[LOG_FIELD_TIMESTAMP_KEY],
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
flattenLogData.timestamp / 1e6,
|
||||
flattenLogData[LOG_FIELD_TIMESTAMP_KEY] / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
),
|
||||
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
|
||||
[flattenLogData, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const logType = getLogIndicatorType(logData);
|
||||
@@ -215,10 +222,12 @@ function ListLogView({
|
||||
/>
|
||||
<div>
|
||||
<LogContainer fontSize={fontSize}>
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
{updatedSelecedFields.some(
|
||||
(field) => field.key === LOG_FIELD_BODY_KEY,
|
||||
) && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={flattenLogData.body}
|
||||
fieldValue={flattenLogData[LOG_FIELD_BODY_KEY]}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
@@ -230,7 +239,9 @@ function ListLogView({
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
{updatedSelecedFields.some((field) => field.name === 'timestamp') && (
|
||||
{updatedSelecedFields.some(
|
||||
(field) => field.key === LOG_FIELD_TIMESTAMP_KEY,
|
||||
) && (
|
||||
<LogGeneralField
|
||||
fieldKey="Timestamp"
|
||||
fieldValue={timestampValue}
|
||||
@@ -239,13 +250,17 @@ function ListLogView({
|
||||
)}
|
||||
|
||||
{updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.filter(
|
||||
(field) =>
|
||||
![LOG_FIELD_TIMESTAMP_KEY, LOG_FIELD_BODY_KEY].includes(field.key),
|
||||
)
|
||||
.map((field) =>
|
||||
isValidLogField(flattenLogData[field.name] as never) ? (
|
||||
isValidLogField(flattenLogData[field.key] as never) ? (
|
||||
<LogSelectedField
|
||||
key={field.name}
|
||||
fieldKey={field.name}
|
||||
fieldValue={flattenLogData[field.name] as never}
|
||||
key={field.key}
|
||||
fieldKey={field.key}
|
||||
fieldKeyDisplay={field.displayName}
|
||||
fieldValue={flattenLogData[field.key] as never}
|
||||
onAddToQuery={onAddToQuery}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
|
||||
@@ -73,16 +73,25 @@ function RawLogView({
|
||||
);
|
||||
|
||||
const attributesValues = updatedSelecedFields
|
||||
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||
.map((field) => flattenLogData[field.name])
|
||||
.filter((attribute) => {
|
||||
.filter(
|
||||
(field) => !['log.timestamp:string', 'log.body:string'].includes(field.key),
|
||||
)
|
||||
.map((field) => {
|
||||
const value = flattenLogData[field.key];
|
||||
const label = field.displayName;
|
||||
|
||||
// loadash isEmpty doesnot work with numbers
|
||||
if (isNumber(attribute)) {
|
||||
return true;
|
||||
if (isNumber(value)) {
|
||||
return `${label}: ${value}`;
|
||||
}
|
||||
|
||||
return !isUndefined(attribute) && !isEmpty(attribute);
|
||||
});
|
||||
if (!isUndefined(value) && !isEmpty(value)) {
|
||||
return `${label}: ${value}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((attribute) => attribute !== null);
|
||||
|
||||
let attributesText = attributesValues.join(' | ');
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import {
|
||||
FlatLogData,
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -51,14 +55,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
|
||||
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
||||
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
||||
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
|
||||
.map(({ name }) => ({
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
accessorKey: name,
|
||||
id: name.toLowerCase().replace(/\./g, '_'),
|
||||
key: name,
|
||||
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
.filter(
|
||||
(e) => !['id', LOG_FIELD_BODY_KEY, LOG_FIELD_TIMESTAMP_KEY].includes(e.key),
|
||||
)
|
||||
.map((field) => ({
|
||||
title: field.displayName,
|
||||
dataIndex: field.key,
|
||||
accessorKey: field.key,
|
||||
id: field.key.toLowerCase().replace(/\./g, '_').replace(/:/g, '_'),
|
||||
key: field.key,
|
||||
render: (fieldValue, record): ColumnTypeRender<Record<string, unknown>> => {
|
||||
const value = record[field.key] || fieldValue;
|
||||
return {
|
||||
props: {
|
||||
style: isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
@@ -69,10 +77,11 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
ellipsis={{ rows: linesPerRow }}
|
||||
className={cx('paragraph', fontSize)}
|
||||
>
|
||||
{field}
|
||||
{value}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
if (isListViewPanel) {
|
||||
@@ -100,26 +109,29 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
),
|
||||
}),
|
||||
},
|
||||
...(fields.some((field) => field.name === 'timestamp')
|
||||
...(fields.some((field) => field.key === LOG_FIELD_TIMESTAMP_KEY)
|
||||
? [
|
||||
{
|
||||
title: 'timestamp',
|
||||
dataIndex: 'timestamp',
|
||||
dataIndex: LOG_FIELD_TIMESTAMP_KEY,
|
||||
key: 'timestamp',
|
||||
accessorKey: 'timestamp',
|
||||
accessorKey: LOG_FIELD_TIMESTAMP_KEY,
|
||||
id: 'timestamp',
|
||||
// https://github.com/ant-design/ant-design/discussions/36886
|
||||
render: (
|
||||
field: string | number,
|
||||
record: Record<string, unknown>,
|
||||
): ColumnTypeRender<Record<string, unknown>> => {
|
||||
const timestampValue =
|
||||
(record[LOG_FIELD_TIMESTAMP_KEY] as string | number) || field;
|
||||
const date =
|
||||
typeof field === 'string'
|
||||
typeof timestampValue === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
field,
|
||||
timestampValue,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
field / 1e6,
|
||||
timestampValue / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return {
|
||||
@@ -136,24 +148,27 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
]
|
||||
: []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(fields.some((field) => field.name === 'body')
|
||||
...(fields.some((field) => field.key === LOG_FIELD_BODY_KEY)
|
||||
? [
|
||||
{
|
||||
title: 'body',
|
||||
dataIndex: 'body',
|
||||
dataIndex: LOG_FIELD_BODY_KEY,
|
||||
key: 'body',
|
||||
accessorKey: 'body',
|
||||
accessorKey: LOG_FIELD_BODY_KEY,
|
||||
id: 'body',
|
||||
render: (
|
||||
field: string | number,
|
||||
): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
record: Record<string, unknown>,
|
||||
): ColumnTypeRender<Record<string, unknown>> => {
|
||||
const bodyValue = (record[LOG_FIELD_BODY_KEY] as string) || '';
|
||||
return {
|
||||
props: {
|
||||
style: bodyColumnStyle,
|
||||
},
|
||||
children: (
|
||||
<TableBodyContent
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(field as string, {
|
||||
__html: getSanitizedLogBody(bodyValue, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
@@ -162,7 +177,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -416,18 +416,21 @@ function OptionsMenu({
|
||||
)}
|
||||
|
||||
<div className="column-format">
|
||||
{addColumn?.value?.map(({ name }) => (
|
||||
<div className="column-name" key={name}>
|
||||
{addColumn?.value?.map((column) => (
|
||||
<div className="column-name" key={column.key}>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={name}>
|
||||
{name}
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={column.displayName || column.name}
|
||||
>
|
||||
{column.displayName || column.name}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(name)}
|
||||
onClick={(): void => addColumn.onRemove(column.key)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,7 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs
|
||||
|
||||
// Map extracted query pairs to key-specific pair information for faster access
|
||||
let queryPairsMap = getQueryPairsMap(existingQuery);
|
||||
let queryPairsMap = getQueryPairsMap(existingQuery.trim());
|
||||
|
||||
filters?.items?.forEach((filter) => {
|
||||
const { key, op, value } = filter;
|
||||
@@ -309,7 +309,7 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
|
||||
notInPair.position.valueEnd + 1,
|
||||
)}`;
|
||||
queryPairsMap = getQueryPairsMap(modifiedQuery);
|
||||
queryPairsMap = getQueryPairsMap(modifiedQuery.trim());
|
||||
}
|
||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||
} else if (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Select } from 'antd';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
@@ -265,7 +264,6 @@ function AllEndPoints({
|
||||
customOnDragSelect={(): void => {}}
|
||||
customTimeRange={timeRange}
|
||||
customOnRowClick={onRowClick}
|
||||
version={ENTITY_VERSION_V5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
@@ -179,33 +178,18 @@ function EndPointDetails({
|
||||
[domainName, filters, minTime, maxTime],
|
||||
);
|
||||
|
||||
const V5_QUERIES = [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
|
||||
] as const;
|
||||
|
||||
const endPointDetailsDataQueries = useQueries(
|
||||
endPointDetailsQueryPayload.map((payload, index) => {
|
||||
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
|
||||
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
|
||||
? ENTITY_VERSION_V5
|
||||
: ENTITY_VERSION_V4;
|
||||
return {
|
||||
endPointDetailsQueryPayload.map((payload, index) => ({
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||
payload,
|
||||
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
|
||||
version,
|
||||
filters?.items, // Include filters.items in queryKey for better caching
|
||||
ENTITY_VERSION_V4,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, version),
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
||||
enabled: !!payload,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
const [
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
@@ -56,10 +56,6 @@ function TopErrors({
|
||||
{
|
||||
items: endPointName
|
||||
? [
|
||||
// Remove any existing http.url filters from initialFilters to avoid duplicates
|
||||
...(initialFilters?.items?.filter(
|
||||
(item) => item.key?.key !== SPAN_ATTRIBUTES.URL_PATH,
|
||||
) || []),
|
||||
{
|
||||
id: '92b8a1c1',
|
||||
key: {
|
||||
@@ -70,6 +66,7 @@ function TopErrors({
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...(initialFilters?.items || []),
|
||||
]
|
||||
: [...(initialFilters?.items || [])],
|
||||
op: 'AND',
|
||||
@@ -131,12 +128,12 @@ function TopErrors({
|
||||
const endPointDropDownDataQueries = useQueries(
|
||||
endPointDropDownQueryPayload.map((payload) => ({
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[4],
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
ENTITY_VERSION_V4,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
||||
enabled: !!payload,
|
||||
staleTime: 60 * 1000,
|
||||
})),
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable prefer-destructuring */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import DomainMetrics from './DomainMetrics';
|
||||
|
||||
// Mock the API call
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock ErrorState component
|
||||
jest.mock('./ErrorState', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ refetch }) => (
|
||||
<div data-testid="error-state">
|
||||
<button type="button" onClick={refetch} data-testid="retry-button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const mockProps = {
|
||||
domainName: '0.0.0.0',
|
||||
timeRange: {
|
||||
startTime: 1758259531000,
|
||||
endTime: 1758261331000,
|
||||
},
|
||||
domainListFilters: {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
} as IBuilderQuery['filters'],
|
||||
};
|
||||
|
||||
const mockSuccessResponse = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [
|
||||
{
|
||||
data: {
|
||||
A: '150',
|
||||
B: '125000000',
|
||||
D: '2021-01-01T23:00:00Z',
|
||||
F1: '5.5',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
const renderComponent = (props = mockProps): ReturnType<typeof render> =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DomainMetrics {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe('1. V5 Query Payload with Filters', () => {
|
||||
it('sends correct V5 payload structure with domain name filters', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [payload, version] = (GetMetricQueryRange as jest.Mock).mock.calls[0];
|
||||
|
||||
// Verify it's using V5
|
||||
expect(version).toBe(ENTITY_VERSION_V5);
|
||||
|
||||
// Verify time range
|
||||
expect(payload.start).toBe(1758259531000);
|
||||
expect(payload.end).toBe(1758261331000);
|
||||
|
||||
// Verify V3 payload structure (getDomainMetricsQueryPayload returns V3 format)
|
||||
expect(payload.query).toBeDefined();
|
||||
expect(payload.query.builder).toBeDefined();
|
||||
expect(payload.query.builder.queryData).toBeDefined();
|
||||
|
||||
const queryData = payload.query.builder.queryData;
|
||||
|
||||
// Verify Query A - count with URL filter
|
||||
const queryA = queryData.find((q: any) => q.queryName === 'A');
|
||||
expect(queryA).toBeDefined();
|
||||
expect(queryA.dataSource).toBe('traces');
|
||||
expect(queryA.aggregations?.[0]).toBeDefined();
|
||||
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'count()',
|
||||
);
|
||||
// Verify exact domain filter expression structure
|
||||
expect(queryA.filter.expression).toContain(
|
||||
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
|
||||
);
|
||||
expect(queryA.filter.expression).toContain(
|
||||
'url.full EXISTS OR http.url EXISTS',
|
||||
);
|
||||
|
||||
// Verify Query B - p99 latency
|
||||
const queryB = queryData.find((q: any) => q.queryName === 'B');
|
||||
expect(queryB).toBeDefined();
|
||||
expect(queryB.aggregateOperator).toBe('p99');
|
||||
expect(queryB.aggregations?.[0]).toBeDefined();
|
||||
expect((queryB.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'p99(duration_nano)',
|
||||
);
|
||||
// Verify exact domain filter expression structure
|
||||
expect(queryB.filter.expression).toContain(
|
||||
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
|
||||
);
|
||||
|
||||
// Verify Query C - error count (disabled)
|
||||
const queryC = queryData.find((q: any) => q.queryName === 'C');
|
||||
expect(queryC).toBeDefined();
|
||||
expect(queryC.disabled).toBe(true);
|
||||
expect(queryC.filter.expression).toContain(
|
||||
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
|
||||
);
|
||||
expect(queryC.aggregations?.[0]).toBeDefined();
|
||||
expect((queryC.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'count()',
|
||||
);
|
||||
|
||||
expect(queryC.filter.expression).toContain('has_error = true');
|
||||
|
||||
// Verify Query D - max timestamp
|
||||
const queryD = queryData.find((q: any) => q.queryName === 'D');
|
||||
expect(queryD).toBeDefined();
|
||||
expect(queryD.aggregateOperator).toBe('max');
|
||||
expect(queryD.aggregations?.[0]).toBeDefined();
|
||||
expect((queryD.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'max(timestamp)',
|
||||
);
|
||||
// Verify exact domain filter expression structure
|
||||
expect(queryD.filter.expression).toContain(
|
||||
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
|
||||
);
|
||||
|
||||
// Verify Formula F1 - error rate calculation
|
||||
const formulas = payload.query.builder.queryFormulas;
|
||||
expect(formulas).toBeDefined();
|
||||
expect(formulas.length).toBeGreaterThan(0);
|
||||
const formulaF1 = formulas.find((f: any) => f.queryName === 'F1');
|
||||
expect(formulaF1).toBeDefined();
|
||||
expect(formulaF1.expression).toBe('(C/A)*100');
|
||||
});
|
||||
|
||||
it('includes custom filters in filter expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'my-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
...mockProps,
|
||||
domainListFilters: customFilters,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [payload] = (GetMetricQueryRange as jest.Mock).mock.calls[0];
|
||||
const queryData = payload.query.builder.queryData;
|
||||
|
||||
// Verify all queries include the custom filters
|
||||
queryData.forEach((query: any) => {
|
||||
if (query.filter && query.filter.expression) {
|
||||
expect(query.filter.expression).toContain('service.name');
|
||||
expect(query.filter.expression).toContain('my-service');
|
||||
expect(query.filter.expression).toContain('deployment.environment');
|
||||
expect(query.filter.expression).toContain('production');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Data Display State', () => {
|
||||
it('displays metrics when data is successfully loaded', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for skeletons to disappear
|
||||
await waitFor(() => {
|
||||
const skeletons = document.querySelectorAll('.ant-skeleton-button');
|
||||
expect(skeletons.length).toBe(0);
|
||||
});
|
||||
|
||||
// Verify all metric labels are displayed
|
||||
expect(screen.getByText('EXTERNAL API')).toBeInTheDocument();
|
||||
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
|
||||
expect(screen.getByText('ERROR %')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST USED')).toBeInTheDocument();
|
||||
|
||||
// Verify metric values are displayed
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.125s')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Empty/Missing Data State', () => {
|
||||
it('displays "-" for missing data values', async () => {
|
||||
const emptyResponse = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(emptyResponse);
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const skeletons = document.querySelectorAll('.ant-skeleton-button');
|
||||
expect(skeletons.length).toBe(0);
|
||||
});
|
||||
|
||||
// When no data, all values should show "-"
|
||||
const dashValues = screen.getAllByText('-');
|
||||
expect(dashValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. Error State', () => {
|
||||
it('displays error state when API call fails', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries API call when retry button is clicked', async () => {
|
||||
let callCount = 0;
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation(() => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(new Error('API Error'));
|
||||
}
|
||||
return Promise.resolve(mockSuccessResponse);
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for error state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click retry
|
||||
const retryButton = screen.getByTestId('retry-button');
|
||||
retryButton.click();
|
||||
|
||||
// Wait for successful load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
DomainMetricsResponseRow,
|
||||
@@ -44,10 +44,10 @@ function DomainMetrics({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_DOMAIN_METRICS_DATA,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
ENTITY_VERSION_V4,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
||||
enabled: !!payload,
|
||||
staleTime: 60 * 1000, // 1 minute stale time : optimize this part
|
||||
})),
|
||||
@@ -132,9 +132,7 @@ function DomainMetrics({
|
||||
) : (
|
||||
<Tooltip title={formattedDomainMetricsData.latency}>
|
||||
<span className="round-metric-tag">
|
||||
{formattedDomainMetricsData.latency !== '-'
|
||||
? `${(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s`
|
||||
: '-'}
|
||||
{(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -145,7 +143,6 @@ function DomainMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={formattedDomainMetricsData.errorRate}>
|
||||
{formattedDomainMetricsData.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
@@ -163,9 +160,6 @@ function DomainMetrics({
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable prefer-destructuring */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
|
||||
import EndPointMetrics from './EndPointMetrics';
|
||||
|
||||
// Mock the API call
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock ErrorState component
|
||||
jest.mock('./ErrorState', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ refetch }) => (
|
||||
<div data-testid="error-state">
|
||||
<button type="button" onClick={refetch} data-testid="retry-button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const mockSuccessResponse = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [
|
||||
{
|
||||
data: {
|
||||
A: '85.5',
|
||||
B: '245000000',
|
||||
D: '2021-01-01T22:30:00Z',
|
||||
F1: '3.2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
// Helper to create mock query result
|
||||
const createMockQueryResult = (
|
||||
response: any,
|
||||
overrides?: Partial<UseQueryResult<SuccessResponse<any>, unknown>>,
|
||||
): UseQueryResult<SuccessResponse<any>, unknown> =>
|
||||
({
|
||||
data: response,
|
||||
error: null,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
isLoading: false,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: true,
|
||||
isSuccess: true,
|
||||
status: 'success' as const,
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: false,
|
||||
isPlaceholderData: false,
|
||||
isPreviousData: false,
|
||||
refetch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
...overrides,
|
||||
} as UseQueryResult<SuccessResponse<any>, unknown>);
|
||||
|
||||
const renderComponent = (
|
||||
endPointMetricsDataQuery: UseQueryResult<SuccessResponse<any>, unknown>,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EndPointMetrics endPointMetricsDataQuery={endPointMetricsDataQuery} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
describe('1. V5 Query Payload with Filters', () => {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
it('sends correct V5 payload structure with domain and endpoint filters', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
const domainName = 'api.example.com';
|
||||
const startTime = 1758259531000;
|
||||
const endTime = 1758261331000;
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
// Get the actual payload that would be generated
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
);
|
||||
|
||||
// First payload is for endpoint metrics
|
||||
const metricsPayload = payloads[0];
|
||||
|
||||
// Verify it's using the correct structure (V3 format for V5 API)
|
||||
expect(metricsPayload.query).toBeDefined();
|
||||
expect(metricsPayload.query.builder).toBeDefined();
|
||||
expect(metricsPayload.query.builder.queryData).toBeDefined();
|
||||
|
||||
const queryData = metricsPayload.query.builder.queryData;
|
||||
|
||||
// Verify Query A - rate with domain and client kind filters
|
||||
const queryA = queryData.find((q: any) => q.queryName === 'A');
|
||||
expect(queryA).toBeDefined();
|
||||
if (queryA) {
|
||||
expect(queryA.dataSource).toBe('traces');
|
||||
expect(queryA.aggregateOperator).toBe('rate');
|
||||
expect(queryA.timeAggregation).toBe('rate');
|
||||
// Verify exact domain filter expression structure
|
||||
if (queryA.filter) {
|
||||
expect(queryA.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
|
||||
);
|
||||
expect(queryA.filter.expression).toContain("kind_string = 'Client'");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Query B - p99 latency with duration_nano
|
||||
const queryB = queryData.find((q: any) => q.queryName === 'B');
|
||||
expect(queryB).toBeDefined();
|
||||
if (queryB) {
|
||||
expect(queryB.aggregateOperator).toBe('p99');
|
||||
if (queryB.aggregateAttribute) {
|
||||
expect(queryB.aggregateAttribute.key).toBe('duration_nano');
|
||||
}
|
||||
expect(queryB.timeAggregation).toBe('p99');
|
||||
// Verify exact domain filter expression structure
|
||||
if (queryB.filter) {
|
||||
expect(queryB.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
|
||||
);
|
||||
expect(queryB.filter.expression).toContain("kind_string = 'Client'");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Query C - error count (disabled)
|
||||
const queryC = queryData.find((q: any) => q.queryName === 'C');
|
||||
expect(queryC).toBeDefined();
|
||||
if (queryC) {
|
||||
expect(queryC.disabled).toBe(true);
|
||||
expect(queryC.aggregateOperator).toBe('count');
|
||||
if (queryC.filter) {
|
||||
expect(queryC.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
|
||||
);
|
||||
expect(queryC.filter.expression).toContain("kind_string = 'Client'");
|
||||
expect(queryC.filter.expression).toContain('has_error = true');
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Query D - max timestamp for last used
|
||||
const queryD = queryData.find((q: any) => q.queryName === 'D');
|
||||
expect(queryD).toBeDefined();
|
||||
if (queryD) {
|
||||
expect(queryD.aggregateOperator).toBe('max');
|
||||
if (queryD.aggregateAttribute) {
|
||||
expect(queryD.aggregateAttribute.key).toBe('timestamp');
|
||||
}
|
||||
expect(queryD.timeAggregation).toBe('max');
|
||||
// Verify exact domain filter expression structure
|
||||
if (queryD.filter) {
|
||||
expect(queryD.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
|
||||
);
|
||||
expect(queryD.filter.expression).toContain("kind_string = 'Client'");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Query E - total count (disabled)
|
||||
const queryE = queryData.find((q: any) => q.queryName === 'E');
|
||||
expect(queryE).toBeDefined();
|
||||
if (queryE) {
|
||||
expect(queryE.disabled).toBe(true);
|
||||
expect(queryE.aggregateOperator).toBe('count');
|
||||
if (queryE.aggregateAttribute) {
|
||||
expect(queryE.aggregateAttribute.key).toBe('span_id');
|
||||
}
|
||||
if (queryE.filter) {
|
||||
expect(queryE.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
|
||||
);
|
||||
expect(queryE.filter.expression).toContain("kind_string = 'Client'");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Formula F1 - error rate calculation
|
||||
const formulas = metricsPayload.query.builder.queryFormulas;
|
||||
expect(formulas).toBeDefined();
|
||||
expect(formulas.length).toBeGreaterThan(0);
|
||||
const formulaF1 = formulas.find((f: any) => f.queryName === 'F1');
|
||||
expect(formulaF1).toBeDefined();
|
||||
if (formulaF1) {
|
||||
expect(formulaF1.expression).toBe('(C/E)*100');
|
||||
expect(formulaF1.disabled).toBe(false);
|
||||
expect(formulaF1.legend).toBe('error percentage');
|
||||
}
|
||||
});
|
||||
|
||||
it('includes custom domainListFilters in all query expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
const customFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'payment-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'staging',
|
||||
},
|
||||
],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
'api.internal.com',
|
||||
1758259531000,
|
||||
1758261331000,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const queryData = payloads[0].query.builder.queryData;
|
||||
|
||||
// Verify ALL queries (A, B, C, D, E) include the custom filters
|
||||
const allQueryNames = ['A', 'B', 'C', 'D', 'E'];
|
||||
allQueryNames.forEach((queryName) => {
|
||||
const query = queryData.find((q: any) => q.queryName === queryName);
|
||||
expect(query).toBeDefined();
|
||||
if (query && query.filter && query.filter.expression) {
|
||||
// Check for exact filter inclusion
|
||||
expect(query.filter.expression).toContain('service.name');
|
||||
expect(query.filter.expression).toContain('payment-service');
|
||||
expect(query.filter.expression).toContain('deployment.environment');
|
||||
expect(query.filter.expression).toContain('staging');
|
||||
// Also verify domain filter is still present
|
||||
expect(query.filter.expression).toContain(
|
||||
"(net.peer.name = 'api.internal.com' OR server.address = 'api.internal.com')",
|
||||
);
|
||||
// Verify client kind filter is present
|
||||
expect(query.filter.expression).toContain("kind_string = 'Client'");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Data Display State', () => {
|
||||
it('displays metrics when data is successfully loaded', async () => {
|
||||
const mockQuery = createMockQueryResult(mockSuccessResponse);
|
||||
|
||||
renderComponent(mockQuery);
|
||||
|
||||
// Wait for skeletons to disappear
|
||||
await waitFor(() => {
|
||||
const skeletons = document.querySelectorAll('.ant-skeleton-button');
|
||||
expect(skeletons.length).toBe(0);
|
||||
});
|
||||
|
||||
// Verify all metric labels are displayed
|
||||
expect(screen.getByText('Rate')).toBeInTheDocument();
|
||||
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
|
||||
expect(screen.getByText('ERROR %')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST USED')).toBeInTheDocument();
|
||||
|
||||
// Verify metric values are displayed
|
||||
expect(screen.getByText('85.5 ops/sec')).toBeInTheDocument();
|
||||
expect(screen.getByText('245ms')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Empty/Missing Data State', () => {
|
||||
it("displays '-' for missing data values", async () => {
|
||||
const emptyResponse = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockQuery = createMockQueryResult(emptyResponse);
|
||||
|
||||
renderComponent(mockQuery);
|
||||
|
||||
await waitFor(() => {
|
||||
const skeletons = document.querySelectorAll('.ant-skeleton-button');
|
||||
expect(skeletons.length).toBe(0);
|
||||
});
|
||||
|
||||
// When no data, all values should show "-"
|
||||
const dashValues = screen.getAllByText('-');
|
||||
// Should have at least 2 dashes (rate and last used - latency shows "-", error % shows progress bar)
|
||||
expect(dashValues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. Error State', () => {
|
||||
it('displays error state when API call fails', async () => {
|
||||
const mockQuery = createMockQueryResult(null, {
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
status: 'error',
|
||||
error: new Error('API Error'),
|
||||
});
|
||||
|
||||
renderComponent(mockQuery);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries API call when retry button is clicked', async () => {
|
||||
const refetch = jest.fn().mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
// Start with error state
|
||||
const mockQuery = createMockQueryResult(null, {
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
status: 'error',
|
||||
error: new Error('API Error'),
|
||||
refetch,
|
||||
});
|
||||
|
||||
const { rerender } = renderComponent(mockQuery);
|
||||
|
||||
// Wait for error state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click retry
|
||||
const retryButton = screen.getByTestId('retry-button');
|
||||
retryButton.click();
|
||||
|
||||
// Verify refetch was called
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate successful refetch by rerendering with success state
|
||||
const successQuery = createMockQueryResult(mockSuccessResponse);
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EndPointMetrics endPointMetricsDataQuery={successQuery} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for successful load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('85.5 ops/sec')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
getDisplayValue,
|
||||
getFormattedEndPointMetricsData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EndPointMetrics({
|
||||
endPointMetricsDataQuery,
|
||||
}: {
|
||||
@@ -74,9 +70,7 @@ function EndPointMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.rate}>
|
||||
<span className="round-metric-tag">
|
||||
{metricsData?.rate !== '-' ? `${metricsData?.rate} ops/sec` : '-'}
|
||||
</span>
|
||||
<span className="round-metric-tag">{metricsData?.rate} ops/sec</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
@@ -85,7 +79,7 @@ function EndPointMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.latency}>
|
||||
{metricsData?.latency !== '-' ? `${metricsData?.latency}ms` : '-'}
|
||||
<span className="round-metric-tag">{metricsData?.latency}ms</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
@@ -94,7 +88,6 @@ function EndPointMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.errorRate}>
|
||||
{metricsData?.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
||||
@@ -110,9 +103,6 @@ function EndPointMetrics({
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
@@ -120,9 +110,7 @@ function EndPointMetrics({
|
||||
{isLoading || isRefetching ? (
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.lastUsed}>
|
||||
{getDisplayValue(metricsData?.lastUsed)}
|
||||
</Tooltip>
|
||||
<Tooltip title={metricsData?.lastUsed}>{metricsData?.lastUsed}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Card } from 'antd';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import GridCard from 'container/GridCardLayout/GridCard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -23,7 +22,6 @@ function MetricOverTimeGraph({
|
||||
customOnDragSelect={(): void => {}}
|
||||
customTimeRange={timeRange}
|
||||
customTimeRangeWindowForCoRelation="5m"
|
||||
version={ENTITY_VERSION_V5}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
endPointStatusCodeColumns,
|
||||
extractPortAndEndpoint,
|
||||
formatDataForTable,
|
||||
getAllEndpointsWidgetData,
|
||||
getCustomFiltersForBarChart,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getFormattedDependentServicesData,
|
||||
getFormattedEndPointDropDownData,
|
||||
getFormattedEndPointMetricsData,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getFormattedEndPointStatusCodeData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
@@ -43,13 +49,119 @@ jest.mock('../utils', () => {
|
||||
});
|
||||
|
||||
describe('API Monitoring Utils', () => {
|
||||
describe('getAllEndpointsWidgetData', () => {
|
||||
it('should create a widget with correct configuration', () => {
|
||||
// Arrange
|
||||
const groupBy = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: 'http.method',
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const domainName = 'test-domain';
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'test-key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getAllEndpointsWidgetData(
|
||||
groupBy as BaseAutocompleteData[],
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeDefined();
|
||||
// Title is a React component, not a string
|
||||
expect(result.title).toBeDefined();
|
||||
expect(result.panelTypes).toBe(PANEL_TYPES.TABLE);
|
||||
|
||||
// Check that each query includes the domainName filter
|
||||
result.query.builder.queryData.forEach((query) => {
|
||||
const serverNameFilter = query.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
);
|
||||
expect(serverNameFilter).toBeDefined();
|
||||
expect(serverNameFilter?.value).toBe(domainName);
|
||||
|
||||
// Check that the custom filters were included
|
||||
const testFilter = query.filters?.items?.find(
|
||||
(item) => item.id === 'test-filter',
|
||||
);
|
||||
expect(testFilter).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify groupBy was included in queries
|
||||
if (result.query.builder.queryData[0].groupBy) {
|
||||
const hasCustomGroupBy = result.query.builder.queryData[0].groupBy.some(
|
||||
(item) => item && item.key === 'http.method',
|
||||
);
|
||||
expect(hasCustomGroupBy).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty groupBy correctly', () => {
|
||||
// Arrange
|
||||
const groupBy: any[] = [];
|
||||
const domainName = 'test-domain';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getAllEndpointsWidgetData(groupBy, domainName, filters);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
// Should only include default groupBy
|
||||
if (result.query.builder.queryData[0].groupBy) {
|
||||
expect(result.query.builder.queryData[0].groupBy.length).toBeGreaterThan(0);
|
||||
// Check that it doesn't have extra group by fields (only defaults)
|
||||
const defaultGroupByLength =
|
||||
result.query.builder.queryData[0].groupBy.length;
|
||||
const resultWithCustomGroupBy = getAllEndpointsWidgetData(
|
||||
[
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: 'custom.field',
|
||||
type: '',
|
||||
},
|
||||
] as BaseAutocompleteData[],
|
||||
domainName,
|
||||
filters,
|
||||
);
|
||||
// Custom groupBy should have more fields than default
|
||||
if (resultWithCustomGroupBy.query.builder.queryData[0].groupBy) {
|
||||
expect(
|
||||
resultWithCustomGroupBy.query.builder.queryData[0].groupBy.length,
|
||||
).toBeGreaterThan(defaultGroupByLength);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for formatDataForTable
|
||||
describe('formatDataForTable', () => {
|
||||
it('should format rows correctly with valid data', () => {
|
||||
const columns = APIMonitoringColumnsMock;
|
||||
const data = [
|
||||
[
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'test-domain', // domainName
|
||||
'10', // endpoints
|
||||
'25', // rps
|
||||
@@ -107,7 +219,6 @@ describe('API Monitoring Utils', () => {
|
||||
const groupBy = [
|
||||
{
|
||||
id: 'group-by-1',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: 'http.method',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
@@ -341,6 +452,243 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEndPointDetailsQueryPayload', () => {
|
||||
it('should generate proper query payload with all parameters', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const startTime = 1609459200000; // 2021-01-01
|
||||
const endTime = 1609545600000; // 2021-01-02
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
dataType: 'string',
|
||||
key: 'test.key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(6); // Should return 6 queries
|
||||
|
||||
// Check that each query includes proper parameters
|
||||
result.forEach((query) => {
|
||||
expect(query).toHaveProperty('start', startTime);
|
||||
expect(query).toHaveProperty('end', endTime);
|
||||
|
||||
// Should have query property with builder data
|
||||
expect(query).toHaveProperty('query');
|
||||
expect(query.query).toHaveProperty('builder');
|
||||
|
||||
// All queries should include the domain filter
|
||||
const {
|
||||
query: {
|
||||
builder: { queryData },
|
||||
},
|
||||
} = query;
|
||||
queryData.forEach((qd) => {
|
||||
if (qd.filters && qd.filters.items) {
|
||||
const serverNameFilter = qd.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
);
|
||||
expect(serverNameFilter).toBeDefined();
|
||||
// Only check if the serverNameFilter exists, as the actual value might vary
|
||||
// depending on implementation details or domain defaults
|
||||
if (serverNameFilter) {
|
||||
expect(typeof serverNameFilter.value).toBe('string');
|
||||
}
|
||||
}
|
||||
|
||||
// Should include our custom filter
|
||||
const customFilter = qd.filters?.items?.find(
|
||||
(item) => item.id === 'test-filter',
|
||||
);
|
||||
expect(customFilter).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateOverTimeWidgetData', () => {
|
||||
it('should generate widget configuration for rate over time', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const endPointName = '/api/test';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getRateOverTimeWidgetData(
|
||||
domainName,
|
||||
endPointName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('title', 'Rate Over Time');
|
||||
// Check only title since description might vary
|
||||
|
||||
// Check query configuration
|
||||
expect(result).toHaveProperty('query');
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(result).toHaveProperty('query.builder.queryData');
|
||||
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
if (domainFilter) {
|
||||
expect(typeof domainFilter.value).toBe('string');
|
||||
}
|
||||
|
||||
// Should have 'rate' time aggregation
|
||||
expect(queryData).toHaveProperty('timeAggregation', 'rate');
|
||||
|
||||
// Should have proper legend that includes endpoint info
|
||||
expect(queryData).toHaveProperty('legend');
|
||||
expect(
|
||||
typeof queryData.legend === 'string' ? queryData.legend : '',
|
||||
).toContain('/api/test');
|
||||
});
|
||||
|
||||
it('should handle case without endpoint name', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const endPointName = '';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getRateOverTimeWidgetData(
|
||||
domainName,
|
||||
endPointName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Legend should be domain name only
|
||||
expect(queryData).toHaveProperty('legend', domainName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatencyOverTimeWidgetData', () => {
|
||||
it('should generate widget configuration for latency over time', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const endPointName = '/api/test';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getLatencyOverTimeWidgetData(
|
||||
domainName,
|
||||
endPointName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('title', 'Latency Over Time');
|
||||
// Check only title since description might vary
|
||||
|
||||
// Check query configuration
|
||||
expect(result).toHaveProperty('query');
|
||||
expect(result).toHaveProperty('query.builder.queryData');
|
||||
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
if (domainFilter) {
|
||||
expect(typeof domainFilter.value).toBe('string');
|
||||
}
|
||||
|
||||
// Should use duration_nano as the aggregate attribute
|
||||
expect(queryData.aggregateAttribute).toHaveProperty('key', 'duration_nano');
|
||||
|
||||
// Should have 'p99' time aggregation
|
||||
expect(queryData).toHaveProperty('timeAggregation', 'p99');
|
||||
});
|
||||
|
||||
it('should handle case without endpoint name', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const endPointName = '';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getLatencyOverTimeWidgetData(
|
||||
domainName,
|
||||
endPointName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Legend should be domain name only
|
||||
expect(queryData).toHaveProperty('legend', domainName);
|
||||
});
|
||||
|
||||
// Changed approach to verify end-to-end behavior for URL with port
|
||||
it('should format legends appropriately for complete URLs with ports', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const endPointName = 'http://example.com:8080/api/test';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Extract what we expect the function to extract
|
||||
const expectedParts = extractPortAndEndpoint(endPointName);
|
||||
|
||||
// Act
|
||||
const result = getLatencyOverTimeWidgetData(
|
||||
domainName,
|
||||
endPointName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Check that legend is present and is a string
|
||||
expect(queryData).toHaveProperty('legend');
|
||||
expect(typeof queryData.legend).toBe('string');
|
||||
|
||||
// If the URL has a port and endpoint, the legend should reflect that appropriately
|
||||
// (Testing the integration rather than the exact formatting)
|
||||
if (expectedParts.port !== '-') {
|
||||
// Verify that both components are incorporated into the legend in some way
|
||||
// This tests the behavior without relying on the exact implementation details
|
||||
const legendStr = queryData.legend as string;
|
||||
expect(legendStr).not.toBe(domainName); // Legend should be different when URL has port/endpoint
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedEndPointDropDownData', () => {
|
||||
it('should format endpoint dropdown data correctly', () => {
|
||||
// Arrange
|
||||
@@ -350,7 +698,6 @@ describe('API Monitoring Utils', () => {
|
||||
data: {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
[URL_PATH_KEY]: '/api/users',
|
||||
'url.full': 'http://example.com/api/users',
|
||||
A: 150, // count or other metric
|
||||
},
|
||||
},
|
||||
@@ -358,7 +705,6 @@ describe('API Monitoring Utils', () => {
|
||||
data: {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
[URL_PATH_KEY]: '/api/orders',
|
||||
'url.full': 'http://example.com/api/orders',
|
||||
A: 75,
|
||||
},
|
||||
},
|
||||
@@ -442,6 +788,87 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedEndPointMetricsData', () => {
|
||||
it('should format endpoint metrics data correctly', () => {
|
||||
// Arrange
|
||||
const mockData = [
|
||||
{
|
||||
data: {
|
||||
A: '50', // rate
|
||||
B: '15000000', // latency in nanoseconds
|
||||
C: '5', // required by type
|
||||
D: '1640995200000000', // timestamp in nanoseconds
|
||||
F1: '5.5', // error rate
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFormattedEndPointMetricsData(mockData as any);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.key).toBeDefined();
|
||||
expect(result.rate).toBe('50');
|
||||
expect(result.latency).toBe(15); // Should be converted from ns to ms
|
||||
expect(result.errorRate).toBe(5.5);
|
||||
expect(typeof result.lastUsed).toBe('string'); // Time formatting is tested elsewhere
|
||||
});
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
it('should handle undefined values in data', () => {
|
||||
// Arrange
|
||||
const mockData = [
|
||||
{
|
||||
data: {
|
||||
A: undefined,
|
||||
B: 'n/a',
|
||||
C: '', // required by type
|
||||
D: undefined,
|
||||
F1: 'n/a',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFormattedEndPointMetricsData(mockData as any);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.rate).toBe('-');
|
||||
expect(result.latency).toBe('-');
|
||||
expect(result.errorRate).toBe(0);
|
||||
expect(result.lastUsed).toBe('-');
|
||||
});
|
||||
|
||||
it('should handle empty input array', () => {
|
||||
// Act
|
||||
const result = getFormattedEndPointMetricsData([]);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.rate).toBe('-');
|
||||
expect(result.latency).toBe('-');
|
||||
expect(result.errorRate).toBe(0);
|
||||
expect(result.lastUsed).toBe('-');
|
||||
});
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
// Arrange
|
||||
const undefinedInput = undefined as any;
|
||||
|
||||
// Act
|
||||
const result = getFormattedEndPointMetricsData(undefinedInput);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.rate).toBe('-');
|
||||
expect(result.latency).toBe('-');
|
||||
expect(result.errorRate).toBe(0);
|
||||
expect(result.lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedEndPointStatusCodeData', () => {
|
||||
it('should format status code data correctly', () => {
|
||||
// Arrange
|
||||
@@ -578,6 +1005,139 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedDependentServicesData', () => {
|
||||
it('should format dependent services data correctly', () => {
|
||||
// Arrange
|
||||
const mockData = [
|
||||
{
|
||||
data: {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'service.name': 'auth-service',
|
||||
A: '500', // count
|
||||
B: '120000000', // latency in nanoseconds
|
||||
C: '15', // rate
|
||||
F1: '2.5', // error percentage
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
'service.name': 'db-service',
|
||||
A: '300',
|
||||
B: '80000000',
|
||||
C: '10',
|
||||
F1: '1.2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFormattedDependentServicesData(mockData as any);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
// Check first service
|
||||
expect(result[0].key).toBeDefined();
|
||||
expect(result[0].serviceData.serviceName).toBe('auth-service');
|
||||
expect(result[0].serviceData.count).toBe(500);
|
||||
expect(typeof result[0].serviceData.percentage).toBe('number');
|
||||
expect(result[0].latency).toBe(120); // Should be converted from ns to ms
|
||||
expect(result[0].rate).toBe('15');
|
||||
expect(result[0].errorPercentage).toBe('2.5');
|
||||
|
||||
// Check second service
|
||||
expect(result[1].serviceData.serviceName).toBe('db-service');
|
||||
expect(result[1].serviceData.count).toBe(300);
|
||||
expect(result[1].latency).toBe(80);
|
||||
expect(result[1].rate).toBe('10');
|
||||
expect(result[1].errorPercentage).toBe('1.2');
|
||||
|
||||
// Verify percentage calculation
|
||||
const totalCount = 500 + 300;
|
||||
expect(result[0].serviceData.percentage).toBeCloseTo(
|
||||
(500 / totalCount) * 100,
|
||||
2,
|
||||
);
|
||||
expect(result[1].serviceData.percentage).toBeCloseTo(
|
||||
(300 / totalCount) * 100,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined values in data', () => {
|
||||
// Arrange
|
||||
const mockData = [
|
||||
{
|
||||
data: {
|
||||
'service.name': 'auth-service',
|
||||
A: 'n/a',
|
||||
B: undefined,
|
||||
C: 'n/a',
|
||||
F1: undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFormattedDependentServicesData(mockData as any);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].serviceData.serviceName).toBe('auth-service');
|
||||
expect(result[0].serviceData.count).toBe('-');
|
||||
expect(result[0].serviceData.percentage).toBe(0);
|
||||
expect(result[0].latency).toBe('-');
|
||||
expect(result[0].rate).toBe('-');
|
||||
expect(result[0].errorPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty input array', () => {
|
||||
// Act
|
||||
const result = getFormattedDependentServicesData([]);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
// Arrange
|
||||
const undefinedInput = undefined as any;
|
||||
|
||||
// Act
|
||||
const result = getFormattedDependentServicesData(undefinedInput);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing service name', () => {
|
||||
// Arrange
|
||||
const mockData = [
|
||||
{
|
||||
data: {
|
||||
// Missing service.name
|
||||
A: '200',
|
||||
B: '50000000',
|
||||
C: '8',
|
||||
F1: '0.5',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFormattedDependentServicesData(mockData as any);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].serviceData.serviceName).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedEndPointStatusCodeChartData', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/**
|
||||
* V5 Migration Tests for All Endpoints Widget (Endpoint Overview)
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for getAllEndpointsWidgetData:
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Aggregation format: aggregateAttribute → aggregations[] array
|
||||
* - Domain filter: (net.peer.name OR server.address)
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
* - Four queries: A (count), B (p99 latency), C (max timestamp), D (error count - disabled)
|
||||
* - GroupBy: Both http.url AND url.full with type 'attribute'
|
||||
*/
|
||||
import { getAllEndpointsWidgetData } from 'container/ApiMonitoring/utils';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('AllEndpointsWidget - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyGroupBy: BaseAutocompleteData[] = [];
|
||||
|
||||
describe('1. V5 Format Migration - All Four Queries', () => {
|
||||
it('all queries use filter.expression format (not filters.items)', () => {
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
emptyGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const { queryData } = widget.query.builder;
|
||||
|
||||
// All 4 queries must use V5 filter.expression format
|
||||
queryData.forEach((query) => {
|
||||
expect(query.filter).toBeDefined();
|
||||
expect(query.filter?.expression).toBeDefined();
|
||||
expect(typeof query.filter?.expression).toBe('string');
|
||||
// OLD V4 format should NOT exist
|
||||
expect(query).not.toHaveProperty('filters');
|
||||
});
|
||||
|
||||
// Verify we have exactly 4 queries
|
||||
expect(queryData).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('all queries use aggregations array format (not aggregateAttribute)', () => {
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
emptyGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
|
||||
|
||||
// Query A: count()
|
||||
expect(queryA.aggregations).toBeDefined();
|
||||
expect(Array.isArray(queryA.aggregations)).toBe(true);
|
||||
expect(queryA.aggregations).toEqual([{ expression: 'count()' }]);
|
||||
expect(queryA).not.toHaveProperty('aggregateAttribute');
|
||||
|
||||
// Query B: p99(duration_nano)
|
||||
expect(queryB.aggregations).toBeDefined();
|
||||
expect(Array.isArray(queryB.aggregations)).toBe(true);
|
||||
expect(queryB.aggregations).toEqual([{ expression: 'p99(duration_nano)' }]);
|
||||
expect(queryB).not.toHaveProperty('aggregateAttribute');
|
||||
|
||||
// Query C: max(timestamp)
|
||||
expect(queryC.aggregations).toBeDefined();
|
||||
expect(Array.isArray(queryC.aggregations)).toBe(true);
|
||||
expect(queryC.aggregations).toEqual([{ expression: 'max(timestamp)' }]);
|
||||
expect(queryC).not.toHaveProperty('aggregateAttribute');
|
||||
|
||||
// Query D: count() (disabled, for errors)
|
||||
expect(queryD.aggregations).toBeDefined();
|
||||
expect(Array.isArray(queryD.aggregations)).toBe(true);
|
||||
expect(queryD.aggregations).toEqual([{ expression: 'count()' }]);
|
||||
expect(queryD).not.toHaveProperty('aggregateAttribute');
|
||||
});
|
||||
|
||||
it('all queries have correct base filter expressions', () => {
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
emptyGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
|
||||
|
||||
const baseExpression = `(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}') AND kind_string = 'Client'`;
|
||||
|
||||
// Queries A, B, C have identical base filter
|
||||
expect(queryA.filter?.expression).toBe(
|
||||
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
|
||||
);
|
||||
expect(queryB.filter?.expression).toBe(
|
||||
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
|
||||
);
|
||||
expect(queryC.filter?.expression).toBe(
|
||||
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
|
||||
);
|
||||
|
||||
// Query D has additional has_error filter
|
||||
expect(queryD.filter?.expression).toBe(
|
||||
`${baseExpression} AND has_error = true AND (http.url EXISTS OR url.full EXISTS)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. GroupBy Structure', () => {
|
||||
it('default groupBy includes both http.url and url.full with type attribute', () => {
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
emptyGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const { queryData } = widget.query.builder;
|
||||
|
||||
// All queries should have the same default groupBy
|
||||
queryData.forEach((query) => {
|
||||
expect(query.groupBy).toHaveLength(2);
|
||||
|
||||
// http.url
|
||||
expect(query.groupBy).toContainEqual({
|
||||
dataType: DataTypes.String,
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'attribute',
|
||||
});
|
||||
|
||||
// url.full
|
||||
expect(query.groupBy).toContainEqual({
|
||||
dataType: DataTypes.String,
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'url.full',
|
||||
type: 'attribute',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('custom groupBy is appended after defaults', () => {
|
||||
const customGroupBy: BaseAutocompleteData[] = [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: 'deployment.environment',
|
||||
type: 'resource',
|
||||
},
|
||||
];
|
||||
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
customGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const { queryData } = widget.query.builder;
|
||||
|
||||
// All queries should have defaults + custom groupBy
|
||||
queryData.forEach((query) => {
|
||||
expect(query.groupBy).toHaveLength(4); // 2 defaults + 2 custom
|
||||
|
||||
// First two should be defaults (http.url, url.full)
|
||||
expect(query.groupBy[0].key).toBe('http.url');
|
||||
expect(query.groupBy[1].key).toBe('url.full');
|
||||
|
||||
// Last two should be custom (matching subset of properties)
|
||||
expect(query.groupBy[2]).toMatchObject({
|
||||
dataType: DataTypes.String,
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
});
|
||||
expect(query.groupBy[3]).toMatchObject({
|
||||
dataType: DataTypes.String,
|
||||
key: 'deployment.environment',
|
||||
type: 'resource',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Query-Specific Validations', () => {
|
||||
it('query D has has_error filter and is disabled', () => {
|
||||
const widget = getAllEndpointsWidgetData(
|
||||
emptyGroupBy,
|
||||
mockDomainName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
|
||||
|
||||
// Query D should be disabled
|
||||
expect(queryD.disabled).toBe(true);
|
||||
|
||||
// Queries A, B, C should NOT be disabled
|
||||
expect(queryA.disabled).toBe(false);
|
||||
expect(queryB.disabled).toBe(false);
|
||||
expect(queryC.disabled).toBe(false);
|
||||
|
||||
// Query D should have has_error in filter
|
||||
expect(queryD.filter?.expression).toContain('has_error = true');
|
||||
|
||||
// Queries A, B, C should NOT have has_error
|
||||
expect(queryA.filter?.expression).not.toContain('has_error');
|
||||
expect(queryB.filter?.expression).not.toContain('has_error');
|
||||
expect(queryC.filter?.expression).not.toContain('has_error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
|
||||
import EndPointMetrics from '../Explorer/Domains/DomainDetails/components/EndPointMetrics';
|
||||
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
|
||||
|
||||
// Create a partial mock of the UseQueryResult interface for testing
|
||||
interface MockQueryResult {
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
isError: boolean;
|
||||
data?: any;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// Mock the utils function
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getFormattedEndPointMetricsData: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ErrorState component
|
||||
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ refetch }) => (
|
||||
<div data-testid="error-state-mock">
|
||||
<button type="button" data-testid="refetch-button" onClick={refetch}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock antd components
|
||||
jest.mock('antd', () => {
|
||||
const originalModule = jest.requireActual('antd');
|
||||
return {
|
||||
...originalModule,
|
||||
Progress: jest
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="progress-bar-mock" />),
|
||||
Skeleton: {
|
||||
Button: jest
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="skeleton-button-mock" />),
|
||||
},
|
||||
Tooltip: jest
|
||||
.fn()
|
||||
.mockImplementation(({ children }) => (
|
||||
<div data-testid="tooltip-mock">{children}</div>
|
||||
)),
|
||||
Typography: {
|
||||
Text: jest.fn().mockImplementation(({ children, className }) => (
|
||||
<div data-testid={`typography-${className}`} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('EndPointMetrics', () => {
|
||||
// Common metric data to use in tests
|
||||
const mockMetricsData = {
|
||||
key: 'test-key',
|
||||
rate: '42',
|
||||
latency: 99,
|
||||
errorRate: 5.5,
|
||||
lastUsed: '5 minutes ago',
|
||||
};
|
||||
|
||||
// Basic props for tests
|
||||
const refetchFn = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(
|
||||
mockMetricsData,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: true,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
|
||||
|
||||
// Verify skeleton loaders are visible
|
||||
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
|
||||
expect(skeletonElements.length).toBe(4);
|
||||
|
||||
// Verify labels are visible even during loading
|
||||
expect(screen.getByText('Rate')).toBeInTheDocument();
|
||||
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
|
||||
expect(screen.getByText('ERROR %')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST USED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
|
||||
|
||||
// Verify error state is shown
|
||||
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
|
||||
expect(ErrorState).toHaveBeenCalledWith(
|
||||
{ refetch: expect.any(Function) },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders data correctly when loaded', () => {
|
||||
const mockData = {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [
|
||||
{ data: { A: '42', B: '99000000', D: '1609459200000000', F1: '5.5' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as SuccessResponse<any>;
|
||||
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
data: mockData,
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
|
||||
|
||||
// Verify the utils function was called with the data
|
||||
expect(getFormattedEndPointMetricsData).toHaveBeenCalledWith(
|
||||
mockData.payload.data.result[0].table.rows,
|
||||
);
|
||||
|
||||
// Verify data is displayed
|
||||
expect(
|
||||
screen.getByText(`${mockMetricsData.rate} ops/sec`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(`${mockMetricsData.latency}ms`)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricsData.lastUsed)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('progress-bar-mock')).toBeInTheDocument(); // For error rate
|
||||
});
|
||||
|
||||
it('handles refetching state correctly', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
|
||||
|
||||
// Verify skeleton loaders are visible during refetching
|
||||
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
|
||||
expect(skeletonElements.length).toBe(4);
|
||||
});
|
||||
|
||||
it('handles null metrics data gracefully', () => {
|
||||
// Mock the utils function to return null to simulate missing data
|
||||
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const mockData = {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as SuccessResponse<any>;
|
||||
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
data: mockData,
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
|
||||
|
||||
// Even with null data, the component should render without crashing
|
||||
expect(screen.getByText('Rate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/**
|
||||
* V5 Migration Tests for Endpoint Dropdown Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for the third payload
|
||||
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain handling: (net.peer.name OR server.address)
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
* - Existence check: (http.url EXISTS OR url.full EXISTS)
|
||||
* - Aggregation: count() expression
|
||||
* - GroupBy: Both http.url AND url.full with type 'attribute'
|
||||
*/
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. V5 Format Migration - Structure and Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// Third payload is the endpoint dropdown query (index 2)
|
||||
const dropdownQuery = payload[2];
|
||||
const queryA = dropdownQuery.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
|
||||
expect(queryA.filter).toBeDefined();
|
||||
expect(queryA.filter?.expression).toBeDefined();
|
||||
expect(typeof queryA.filter?.expression).toBe('string');
|
||||
expect(queryA).not.toHaveProperty('filters');
|
||||
|
||||
// Base filter 1: Domain (net.peer.name OR server.address)
|
||||
expect(queryA.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Base filter 2: Kind
|
||||
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
|
||||
|
||||
// Base filter 3: Existence check
|
||||
expect(queryA.filter?.expression).toContain(
|
||||
'(http.url EXISTS OR url.full EXISTS)',
|
||||
);
|
||||
|
||||
// V5 Aggregation format: aggregations array (not aggregateAttribute)
|
||||
expect(queryA.aggregations).toBeDefined();
|
||||
expect(Array.isArray(queryA.aggregations)).toBe(true);
|
||||
expect(queryA.aggregations?.[0]).toEqual({
|
||||
expression: 'count()',
|
||||
});
|
||||
expect(queryA).not.toHaveProperty('aggregateAttribute');
|
||||
|
||||
// GroupBy: Both http.url and url.full
|
||||
expect(queryA.groupBy).toHaveLength(2);
|
||||
expect(queryA.groupBy).toContainEqual({
|
||||
key: 'http.url',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
});
|
||||
expect(queryA.groupBy).toContainEqual({
|
||||
key: 'url.full',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const dropdownQuery = payload[2];
|
||||
const expression =
|
||||
dropdownQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// Exact filter expression with custom filters merged
|
||||
expect(expression).toBe(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND deployment.environment = 'production'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. HTTP URL Filter Special Handling', () => {
|
||||
it('converts http.url filter to (http.url OR url.full) expression', () => {
|
||||
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'http-url-filter',
|
||||
key: {
|
||||
key: 'http.url',
|
||||
dataType: 'string' as any,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: '/api/users',
|
||||
},
|
||||
{
|
||||
id: 'service-filter',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
filtersWithHttpUrl,
|
||||
);
|
||||
|
||||
const dropdownQuery = payload[2];
|
||||
const expression =
|
||||
dropdownQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// CRITICAL: Exact filter expression with http.url converted to OR logic
|
||||
expect(expression).toBe(
|
||||
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND (http.url = '/api/users' OR url.full = '/api/users')",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import {
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const mockEndpointName = '/api/users';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. Rate Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL: Must use V5 format (filter.expression), not V3 format (filters.items)
|
||||
expect(queryData.filter).toBeDefined();
|
||||
expect(queryData?.filter?.expression).toBeDefined();
|
||||
expect(typeof queryData?.filter?.expression).toBe('string');
|
||||
|
||||
// OLD V3 format should NOT exist
|
||||
expect(queryData).not.toHaveProperty('filters.items');
|
||||
});
|
||||
|
||||
it('uses new domain filter format: (net.peer.name OR server.address)', () => {
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// Verify EXACT new filter format with OR operator
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Endpoint name is used in legend, not filter
|
||||
expect(queryData.legend).toContain('/api/users');
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Verify custom filters are merged into the expression
|
||||
expect(queryData?.filter?.expression).toContain('service.name');
|
||||
expect(queryData?.filter?.expression).toContain('user-service');
|
||||
expect(queryData?.filter?.expression).toContain('deployment.environment');
|
||||
expect(queryData?.filter?.expression).toContain('production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Latency Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL: Must use V5 format (filter.expression), not V3 format (filters.items)
|
||||
expect(queryData.filter).toBeDefined();
|
||||
expect(queryData?.filter?.expression).toBeDefined();
|
||||
expect(typeof queryData?.filter?.expression).toBe('string');
|
||||
|
||||
// OLD V3 format should NOT exist
|
||||
expect(queryData).not.toHaveProperty('filters.items');
|
||||
});
|
||||
|
||||
it('uses new domain filter format: (net.peer.name OR server.address)', () => {
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// Verify EXACT new filter format with OR operator
|
||||
expect(queryData.filter).toBeDefined();
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Endpoint name is used in legend, not filter
|
||||
expect(queryData.legend).toContain('/api/users');
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
mockEndpointName,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const queryData = widget.query.builder.queryData[0];
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}') service.name = 'user-service'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/**
|
||||
* V5 Migration Tests for Status Code Bar Chart Queries
|
||||
*
|
||||
* These tests validate the migration to V5 format for the bar chart payloads
|
||||
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
|
||||
* - Number of Calls Chart (count aggregation)
|
||||
* - Latency Chart (p99 aggregation)
|
||||
*
|
||||
* V5 Changes:
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain filter: (net.peer.name OR server.address)
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
* - stepInterval: 60 → null
|
||||
* - Grouped by response_status_code
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
const mockDomainName = '0.0.0.0';
|
||||
const mockStartTime = 1762573673000;
|
||||
const mockEndTime = 1762832873000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for count aggregation grouped by status code', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 5th payload (index 4) is the number of calls bar chart
|
||||
const callsChartQuery = payload[4];
|
||||
const queryA = callsChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
expect(queryA.filter).toBeDefined();
|
||||
expect(queryA.filter?.expression).toBeDefined();
|
||||
expect(typeof queryA.filter?.expression).toBe('string');
|
||||
expect(queryA).not.toHaveProperty('filters.items');
|
||||
|
||||
// Base filter 1: Domain (net.peer.name OR server.address)
|
||||
expect(queryA.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Base filter 2: Kind
|
||||
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
|
||||
|
||||
// Aggregation: count
|
||||
expect(queryA.queryName).toBe('A');
|
||||
expect(queryA.aggregateOperator).toBe('count');
|
||||
expect(queryA.disabled).toBe(false);
|
||||
|
||||
// Grouped by response_status_code
|
||||
expect(queryA.groupBy).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: 'response_status_code',
|
||||
dataType: 'string',
|
||||
type: 'span',
|
||||
}),
|
||||
);
|
||||
|
||||
// V5 critical: stepInterval should be null
|
||||
expect(queryA.stepInterval).toBeNull();
|
||||
|
||||
// Time aggregation
|
||||
expect(queryA.timeAggregation).toBe('rate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Latency Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for p99 aggregation grouped by status code', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 6th payload (index 5) is the latency bar chart
|
||||
const latencyChartQuery = payload[5];
|
||||
const queryA = latencyChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
expect(queryA.filter).toBeDefined();
|
||||
expect(queryA.filter?.expression).toBeDefined();
|
||||
expect(typeof queryA.filter?.expression).toBe('string');
|
||||
expect(queryA).not.toHaveProperty('filters.items');
|
||||
|
||||
// Base filter 1: Domain (net.peer.name OR server.address)
|
||||
expect(queryA.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Base filter 2: Kind
|
||||
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
|
||||
|
||||
// Aggregation: p99 on duration_nano
|
||||
expect(queryA.queryName).toBe('A');
|
||||
expect(queryA.aggregateOperator).toBe('p99');
|
||||
expect(queryA.aggregations?.[0]).toBeDefined();
|
||||
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'p99(duration_nano)',
|
||||
);
|
||||
expect(queryA.disabled).toBe(false);
|
||||
|
||||
// Grouped by response_status_code
|
||||
expect(queryA.groupBy).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: 'response_status_code',
|
||||
dataType: 'string',
|
||||
type: 'span',
|
||||
}),
|
||||
);
|
||||
|
||||
// V5 critical: stepInterval should be null
|
||||
expect(queryA.stepInterval).toBeNull();
|
||||
|
||||
// Time aggregation
|
||||
expect(queryA.timeAggregation).toBe('p99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression for both charts', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
const latencyExpression =
|
||||
latencyChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// Both charts should have the same filter expression
|
||||
expect(callsExpression).toBe(latencyExpression);
|
||||
|
||||
// Verify base filters
|
||||
expect(callsExpression).toContain('net.peer.name');
|
||||
expect(callsExpression).toContain("kind_string = 'Client'");
|
||||
|
||||
// Verify custom filters are merged
|
||||
expect(callsExpression).toContain('service.name');
|
||||
expect(callsExpression).toContain('user-service');
|
||||
expect(callsExpression).toContain('deployment.environment');
|
||||
expect(callsExpression).toContain('production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. HTTP URL Filter Handling', () => {
|
||||
it('converts http.url filter to (http.url OR url.full) expression in both charts', () => {
|
||||
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'http-url-filter',
|
||||
key: {
|
||||
key: 'http.url',
|
||||
dataType: 'string' as any,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: '/api/metrics',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
filtersWithHttpUrl,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
const latencyExpression =
|
||||
latencyChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// CRITICAL: http.url converted to OR logic
|
||||
expect(callsExpression).toContain(
|
||||
"(http.url = '/api/metrics' OR url.full = '/api/metrics')",
|
||||
);
|
||||
expect(latencyExpression).toContain(
|
||||
"(http.url = '/api/metrics' OR url.full = '/api/metrics')",
|
||||
);
|
||||
|
||||
// Base filters still present
|
||||
expect(callsExpression).toContain('net.peer.name');
|
||||
expect(callsExpression).toContain("kind_string = 'Client'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/**
|
||||
* V5 Migration Tests for Status Code Table Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for the second payload
|
||||
* in getEndPointDetailsQueryPayload (status code table data):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - URL handling: Special logic for (http.url OR url.full)
|
||||
* - Domain filter: (net.peer.name OR server.address)
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
* - Kind filter: response_status_code EXISTS
|
||||
* - Three queries: A (count), B (p99 latency), C (rate)
|
||||
* - All grouped by response_status_code
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. V5 Format Migration with Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure and base filters', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// Second payload is the status code table query
|
||||
const statusCodeQuery = payload[1];
|
||||
const queryA = statusCodeQuery.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
|
||||
expect(queryA.filter).toBeDefined();
|
||||
expect(queryA.filter?.expression).toBeDefined();
|
||||
expect(typeof queryA.filter?.expression).toBe('string');
|
||||
expect(queryA).not.toHaveProperty('filters.items');
|
||||
|
||||
// Base filter 1: Domain (net.peer.name OR server.address)
|
||||
expect(queryA.filter?.expression).toContain(
|
||||
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
|
||||
);
|
||||
|
||||
// Base filter 2: Kind
|
||||
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
|
||||
|
||||
// Base filter 3: response_status_code EXISTS
|
||||
expect(queryA.filter?.expression).toContain('response_status_code EXISTS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. Three Queries Structure and Consistency', () => {
|
||||
it('generates three queries (count, p99, rate) all grouped by response_status_code with identical filters', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
const statusCodeQuery = payload[1];
|
||||
const [queryA, queryB, queryC] = statusCodeQuery.query.builder.queryData;
|
||||
|
||||
// Query A: Count
|
||||
expect(queryA.queryName).toBe('A');
|
||||
expect(queryA.aggregateOperator).toBe('count');
|
||||
expect(queryA.aggregations?.[0]).toBeDefined();
|
||||
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'count(span_id)',
|
||||
);
|
||||
expect(queryA.disabled).toBe(false);
|
||||
|
||||
// Query B: P99 Latency
|
||||
expect(queryB.queryName).toBe('B');
|
||||
expect(queryB.aggregateOperator).toBe('p99');
|
||||
expect((queryB.aggregations?.[0] as TraceAggregation)?.expression).toBe(
|
||||
'p99(duration_nano)',
|
||||
);
|
||||
expect(queryB.disabled).toBe(false);
|
||||
|
||||
// Query C: Rate
|
||||
expect(queryC.queryName).toBe('C');
|
||||
expect(queryC.aggregateOperator).toBe('rate');
|
||||
expect(queryC.disabled).toBe(false);
|
||||
|
||||
// All group by response_status_code
|
||||
[queryA, queryB, queryC].forEach((query) => {
|
||||
expect(query.groupBy).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: 'response_status_code',
|
||||
dataType: 'string',
|
||||
type: 'span',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// CRITICAL: All have identical filter expressions
|
||||
expect(queryA.filter?.expression).toBe(queryB.filter?.expression);
|
||||
expect(queryB.filter?.expression).toBe(queryC.filter?.expression);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const statusCodeQuery = payload[1];
|
||||
const expression =
|
||||
statusCodeQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// Base filters present
|
||||
expect(expression).toContain('net.peer.name');
|
||||
expect(expression).toContain("kind_string = 'Client'");
|
||||
expect(expression).toContain('response_status_code EXISTS');
|
||||
|
||||
// Custom filters merged
|
||||
expect(expression).toContain('service.name');
|
||||
expect(expression).toContain('user-service');
|
||||
expect(expression).toContain('deployment.environment');
|
||||
expect(expression).toContain('production');
|
||||
|
||||
// All three queries have the same merged expression
|
||||
const queries = statusCodeQuery.query.builder.queryData;
|
||||
expect(queries[0].filter?.expression).toBe(queries[1].filter?.expression);
|
||||
expect(queries[1].filter?.expression).toBe(queries[2].filter?.expression);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. HTTP URL Filter Handling', () => {
|
||||
it('converts http.url filter to (http.url OR url.full) expression', () => {
|
||||
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'http-url-filter',
|
||||
key: {
|
||||
key: 'http.url',
|
||||
dataType: 'string' as any,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: '/api/users',
|
||||
},
|
||||
{
|
||||
id: 'service-filter',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
filtersWithHttpUrl,
|
||||
);
|
||||
|
||||
const statusCodeQuery = payload[1];
|
||||
const expression =
|
||||
statusCodeQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// CRITICAL: http.url converted to OR logic
|
||||
expect(expression).toContain(
|
||||
"(http.url = '/api/users' OR url.full = '/api/users')",
|
||||
);
|
||||
|
||||
// Other filters still present
|
||||
expect(expression).toContain('service.name');
|
||||
expect(expression).toContain('user-service');
|
||||
|
||||
// Base filters present
|
||||
expect(expression).toContain('net.peer.name');
|
||||
expect(expression).toContain("kind_string = 'Client'");
|
||||
expect(expression).toContain('response_status_code EXISTS');
|
||||
|
||||
// All ANDed together (at least 2 ANDs: domain+kind, custom filter, url condition)
|
||||
expect(expression?.match(/AND/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import { BuilderQuery } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
|
||||
import { getTopErrorsQueryPayload } from '../utils';
|
||||
|
||||
// Mock the EndPointsDropDown component to avoid issues
|
||||
jest.mock(
|
||||
@@ -38,7 +36,6 @@ describe('TopErrors', () => {
|
||||
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
|
||||
|
||||
const mockProps = {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
domainName: 'test-domain',
|
||||
timeRange: {
|
||||
startTime: 1000000000,
|
||||
@@ -308,14 +305,45 @@ describe('TopErrors', () => {
|
||||
});
|
||||
|
||||
it('sends query_range v5 API call with required filters including has_error', async () => {
|
||||
// let capturedRequest: any;
|
||||
let capturedRequest: any;
|
||||
|
||||
const topErrorsPayload = getTopErrorsQueryPayload(
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
{ items: [], op: 'AND' },
|
||||
false,
|
||||
// Override the v5 API mock to capture the request
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
|
||||
capturedRequest = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
|
||||
],
|
||||
data: [['/api/test', '500', 'Internal Server Error', 10]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
@@ -323,18 +351,20 @@ describe('TopErrors', () => {
|
||||
|
||||
// Wait for the API call to be made
|
||||
await waitFor(() => {
|
||||
expect(topErrorsPayload).toBeDefined();
|
||||
expect(capturedRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Extract the filter expression from the captured request
|
||||
// getTopErrorsQueryPayload returns a builder_query with TraceBuilderQuery spec
|
||||
const builderQuery = topErrorsPayload.compositeQuery.queries[0]
|
||||
.spec as BuilderQuery;
|
||||
const filterExpression = builderQuery.filter?.expression;
|
||||
const filterExpression =
|
||||
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
|
||||
|
||||
// Verify all required filters are present
|
||||
expect(filterExpression).toContain(`kind_string = 'Client'`);
|
||||
expect(filterExpression).toContain(`(http.url EXISTS OR url.full EXISTS)`);
|
||||
expect(filterExpression).toContain(
|
||||
`kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) AND (net.peer.name = 'test-domain' OR server.address = 'test-domain') AND has_error = true`,
|
||||
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
|
||||
);
|
||||
expect(filterExpression).toContain(`has_error = true`);
|
||||
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,8 +112,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
setShowPaymentFailedWarning,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const errorBoundaryRef = useRef<Sentry.ErrorBoundary>(null);
|
||||
|
||||
const [showSlowApiWarning, setShowSlowApiWarning] = useState(false);
|
||||
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
|
||||
|
||||
@@ -380,13 +378,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
getChangelogByVersionResponse.isSuccess,
|
||||
]);
|
||||
|
||||
// reset error boundary on route change
|
||||
useEffect(() => {
|
||||
if (errorBoundaryRef.current) {
|
||||
errorBoundaryRef.current.resetErrorBoundary();
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
@@ -845,10 +836,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
})}
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={<ErrorBoundaryFallback />}
|
||||
ref={errorBoundaryRef}
|
||||
>
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<LayoutContent data-overlayscrollbars-initialize>
|
||||
<OverlayScrollbar>
|
||||
<ChildrenContainer>
|
||||
|
||||
@@ -17,7 +17,6 @@ function ExplorerOptionWrapper({
|
||||
isOneChartPerQuery,
|
||||
splitedQueries,
|
||||
signalSource,
|
||||
handleChangeSelectedView,
|
||||
}: ExplorerOptionsWrapperProps): JSX.Element {
|
||||
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
|
||||
|
||||
@@ -39,7 +38,6 @@ function ExplorerOptionWrapper({
|
||||
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,11 +72,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { panelTypeToExplorerView } from 'utils/explorerUtils';
|
||||
|
||||
import { PreservedViewsTypes } from './constants';
|
||||
import ExplorerOptionsHideArea from './ExplorerOptionsHideArea';
|
||||
import { ChangeViewFunctionType, PreservedViewsInLocalStorage } from './types';
|
||||
import { PreservedViewsInLocalStorage } from './types';
|
||||
import {
|
||||
DATASOURCE_VS_ROUTES,
|
||||
generateRGBAFromHex,
|
||||
@@ -99,7 +98,6 @@ function ExplorerOptions({
|
||||
setIsExplorerOptionHidden,
|
||||
isOneChartPerQuery = false,
|
||||
splitedQueries = [],
|
||||
handleChangeSelectedView,
|
||||
}: ExplorerOptionsProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
@@ -414,22 +412,13 @@ function ExplorerOptions({
|
||||
if (!currentViewDetails) return;
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
if (handleChangeSelectedView) {
|
||||
handleChangeSelectedView(panelTypeToExplorerView[currentPanelType], {
|
||||
query,
|
||||
name,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
// to remove this after traces cleanup
|
||||
handleExplorerTabChange(currentPanelType, {
|
||||
query,
|
||||
name,
|
||||
id,
|
||||
});
|
||||
}
|
||||
},
|
||||
[viewsData, handleExplorerTabChange, handleChangeSelectedView],
|
||||
[viewsData, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const updatePreservedViewInLocalStorage = (option: {
|
||||
@@ -535,10 +524,6 @@ function ExplorerOptions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleChangeSelectedView) {
|
||||
handleChangeSelectedView(panelTypeToExplorerView[PANEL_TYPES.LIST]);
|
||||
}
|
||||
|
||||
history.replace(DATASOURCE_VS_ROUTES[sourcepage]);
|
||||
};
|
||||
|
||||
@@ -1035,7 +1020,6 @@ export interface ExplorerOptionsProps {
|
||||
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
|
||||
isOneChartPerQuery?: boolean;
|
||||
splitedQueries?: Query[];
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
}
|
||||
|
||||
ExplorerOptions.defaultProps = {
|
||||
@@ -1045,7 +1029,6 @@ ExplorerOptions.defaultProps = {
|
||||
isOneChartPerQuery: false,
|
||||
splitedQueries: [],
|
||||
signalSource: '',
|
||||
handleChangeSelectedView: undefined,
|
||||
};
|
||||
|
||||
export default ExplorerOptions;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewWithNameProps } from 'components/ExplorerCard/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseMutateAsyncFunction } from 'react-query';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
@@ -40,8 +38,3 @@ export type PreservedViewType =
|
||||
export type PreservedViewsInLocalStorage = Partial<
|
||||
Record<PreservedViewType, { key: string; value: string }>
|
||||
>;
|
||||
|
||||
export type ChangeViewFunctionType = (
|
||||
view: ExplorerViews,
|
||||
querySearchParameters?: ICurrentQueryData,
|
||||
) => void;
|
||||
|
||||
@@ -49,29 +49,17 @@ function GridTableComponent({
|
||||
panelType,
|
||||
queryRangeRequest,
|
||||
decimalPrecision,
|
||||
hiddenColumns = [],
|
||||
...props
|
||||
}: GridTableComponentProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
|
||||
// create columns and dataSource in the ui friendly structure
|
||||
// use the query from the widget here to extract the legend information
|
||||
const { columns: allColumns, dataSource: originalDataSource } = useMemo(
|
||||
const { columns, dataSource: originalDataSource } = useMemo(
|
||||
() => createColumnsAndDataSource((data as unknown) as TableData, query),
|
||||
[query, data],
|
||||
);
|
||||
|
||||
// Filter out hidden columns from being displayed
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
allColumns.filter(
|
||||
(column) =>
|
||||
!('dataIndex' in column) ||
|
||||
!hiddenColumns.includes(column.dataIndex as string),
|
||||
),
|
||||
[allColumns, hiddenColumns],
|
||||
);
|
||||
|
||||
const createDataInCorrectFormat = useCallback(
|
||||
(dataSource: RowData[]): RowData[] =>
|
||||
dataSource.map((d) => {
|
||||
|
||||
@@ -30,7 +30,6 @@ export type GridTableComponentProps = {
|
||||
contextLinks?: ContextLinksData;
|
||||
panelType?: PANEL_TYPES;
|
||||
queryRangeRequest?: QueryRangeRequestV5;
|
||||
hiddenColumns?: string[];
|
||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
@@ -75,11 +79,15 @@ function EntityLogs({
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
displayName: 'Body',
|
||||
key: LOG_FIELD_BODY_KEY,
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
displayName: 'Timestamp',
|
||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
.template-list-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 12px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
@@ -76,10 +76,8 @@
|
||||
|
||||
.template-icon {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 2px;
|
||||
padding: 4px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -99,17 +97,6 @@
|
||||
&.active {
|
||||
border-radius: 3px;
|
||||
background: rgba(171, 189, 255, 0.08);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,38 +159,18 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin: 24px;
|
||||
height: calc(100% - 144px);
|
||||
position: relative;
|
||||
|
||||
&-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 48px 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-ink-50);
|
||||
background: linear-gradient(98.66deg, #7a97fa 4.42%, #f977ff 96.6%);
|
||||
max-height: 100%;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: url('/public/Images/grains.png');
|
||||
background-size: contain;
|
||||
background-repeat: repeat;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 540px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--bg-ink-50);
|
||||
background: var(--bg-ink-300);
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Input, Modal, Typography } from 'antd';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
@@ -17,14 +16,7 @@ import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ConciergeBell, DraftingCompass, Drill, Plus, X } from 'lucide-react';
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -170,9 +162,7 @@ export default function DashboardTemplatesModal({
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="Search..."
|
||||
size="middle"
|
||||
prefix={<Search size={12} color={Color.TEXT_VANILLA_400} />}
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
@@ -222,7 +212,6 @@ export default function DashboardTemplatesModal({
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<div className="template-preview-image-container">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
@@ -231,7 +220,6 @@ export default function DashboardTemplatesModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import './DashboardList.styles.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
// MenuProps,
|
||||
MenuProps,
|
||||
Modal,
|
||||
Popover,
|
||||
Skeleton,
|
||||
@@ -46,14 +47,14 @@ import {
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Expand,
|
||||
// ExternalLink,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
// Github,
|
||||
Github,
|
||||
HdmiPort,
|
||||
// LayoutGrid,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Plus,
|
||||
// Radius,
|
||||
Radius,
|
||||
RotateCw,
|
||||
Search,
|
||||
SquareArrowOutUpRight,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
Key,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -595,61 +597,61 @@ function DashboardsList(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
// const getCreateDashboardItems = useMemo(() => {
|
||||
// const menuItems: MenuProps['items'] = [
|
||||
// {
|
||||
// label: (
|
||||
// <div
|
||||
// className="create-dashboard-menu-item"
|
||||
// onClick={(): void => onModalHandler(false)}
|
||||
// >
|
||||
// <Radius size={14} /> Import JSON
|
||||
// </div>
|
||||
// ),
|
||||
// key: '1',
|
||||
// },
|
||||
// {
|
||||
// label: (
|
||||
// <a
|
||||
// href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
|
||||
// target="_blank"
|
||||
// rel="noopener noreferrer"
|
||||
// >
|
||||
// <Flex
|
||||
// justify="space-between"
|
||||
// align="center"
|
||||
// style={{ width: '100%' }}
|
||||
// gap="small"
|
||||
// >
|
||||
// <div className="create-dashboard-menu-item">
|
||||
// <Github size={14} /> View templates
|
||||
// </div>
|
||||
// <ExternalLink size={14} />
|
||||
// </Flex>
|
||||
// </a>
|
||||
// ),
|
||||
// key: '2',
|
||||
// },
|
||||
// ];
|
||||
const getCreateDashboardItems = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
onClick={(): void => onModalHandler(false)}
|
||||
>
|
||||
<Radius size={14} /> Import JSON
|
||||
</div>
|
||||
),
|
||||
key: '1',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ width: '100%' }}
|
||||
gap="small"
|
||||
>
|
||||
<div className="create-dashboard-menu-item">
|
||||
<Github size={14} /> View templates
|
||||
</div>
|
||||
<ExternalLink size={14} />
|
||||
</Flex>
|
||||
</a>
|
||||
),
|
||||
key: '2',
|
||||
},
|
||||
];
|
||||
|
||||
// if (createNewDashboard) {
|
||||
// menuItems.unshift({
|
||||
// label: (
|
||||
// <div
|
||||
// className="create-dashboard-menu-item"
|
||||
// onClick={(): void => {
|
||||
// onNewDashboardHandler();
|
||||
// }}
|
||||
// >
|
||||
// <LayoutGrid size={14} /> Create dashboard
|
||||
// </div>
|
||||
// ),
|
||||
// key: '0',
|
||||
// });
|
||||
// }
|
||||
if (createNewDashboard) {
|
||||
menuItems.unshift({
|
||||
label: (
|
||||
<div
|
||||
className="create-dashboard-menu-item"
|
||||
onClick={(): void => {
|
||||
onNewDashboardHandler();
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
key: '0',
|
||||
});
|
||||
}
|
||||
|
||||
// return menuItems;
|
||||
// }, [createNewDashboard, onNewDashboardHandler]);
|
||||
return menuItems;
|
||||
}, [createNewDashboard, onNewDashboardHandler]);
|
||||
|
||||
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
@@ -761,6 +763,12 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
{createNewDashboard && (
|
||||
<section className="actions">
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="new-dashboard"
|
||||
@@ -771,6 +779,7 @@ function DashboardsList(): JSX.Element {
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
type="text"
|
||||
className="learn-more"
|
||||
@@ -798,17 +807,23 @@ function DashboardsList(): JSX.Element {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{createNewDashboard && (
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary btn"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
setShowNewDashboardTemplatesModal(true);
|
||||
}}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
|
||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
@@ -57,10 +56,7 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
|
||||
[formattedLogs, activeLogId],
|
||||
);
|
||||
|
||||
const selectedFields = convertKeysToColumnFields([
|
||||
...defaultLogsSelectedColumns,
|
||||
...options.selectColumns,
|
||||
]);
|
||||
const selectedFields = convertKeysToColumnFields(options.selectColumns);
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, log: ILog): JSX.Element => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQuery
|
||||
import { logsQueryRangeEmptyResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
@@ -121,12 +122,12 @@ describe('LogsExplorerList - empty states', () => {
|
||||
<QueryBuilderContext.Provider value={mockTraceToLogsContextValue as any}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
@@ -186,12 +187,12 @@ describe('LogsExplorerList - empty states', () => {
|
||||
<QueryBuilderContext.Provider value={mockTraceToLogsContextValue as any}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
|
||||
@@ -13,7 +13,9 @@ export const convertKeysToColumnFields = (
|
||||
.filter((item) => !isEmpty(item.name))
|
||||
.map((item) => ({
|
||||
dataType: item.fieldDataType ?? '',
|
||||
name: item.name,
|
||||
name: item.name ?? '',
|
||||
key: item.key ?? '',
|
||||
displayName: item.displayName ?? '',
|
||||
type: item.fieldContext ?? '',
|
||||
}));
|
||||
/**
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import {
|
||||
initialQueryBuilderFormValues,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const getListQuery = (
|
||||
stagedQuery: Query | null,
|
||||
): IBuilderQuery | null => {
|
||||
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
|
||||
|
||||
return stagedQuery.builder.queryData[0] ?? null;
|
||||
};
|
||||
|
||||
export const getFrequencyChartData = (
|
||||
stagedQuery: Query | null,
|
||||
activeLogId: string | null,
|
||||
): Query | null => {
|
||||
if (!stagedQuery) {
|
||||
return null;
|
||||
}
|
||||
const baseFirstQuery = getListQuery(stagedQuery);
|
||||
|
||||
if (!baseFirstQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let updatedFilterExpression = baseFirstQuery.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
const modifiedQueryData: IBuilderQuery = {
|
||||
...baseFirstQuery,
|
||||
disabled: false,
|
||||
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||
filter: {
|
||||
...baseFirstQuery.filter,
|
||||
expression: updatedFilterExpression || '',
|
||||
},
|
||||
...(activeLogId && {
|
||||
filters: {
|
||||
...baseFirstQuery.filters,
|
||||
items: [
|
||||
...(baseFirstQuery?.filters?.items || []),
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
key: 'id',
|
||||
type: '',
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: activeLogId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
}),
|
||||
groupBy: [
|
||||
{
|
||||
key: 'severity_text',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'severity_text--string----true',
|
||||
},
|
||||
],
|
||||
legend: '{{severity_text}}',
|
||||
orderBy: [],
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
};
|
||||
|
||||
const modifiedQuery: Query = {
|
||||
...stagedQuery,
|
||||
builder: {
|
||||
...stagedQuery.builder,
|
||||
queryData: [modifiedQueryData], // single query data required for list chart
|
||||
},
|
||||
};
|
||||
|
||||
return modifiedQuery;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
query: Query | null,
|
||||
selectedPanelType: PANEL_TYPES,
|
||||
params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
filters?: TagFilter;
|
||||
filter?: Filter;
|
||||
activeLogId?: string | null;
|
||||
orderBy?: string;
|
||||
},
|
||||
): Query | null => {
|
||||
if (!query) return null;
|
||||
|
||||
let queryData: IBuilderQuery[] = query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
}));
|
||||
|
||||
if (selectedPanelType === PANEL_TYPES.LIST) {
|
||||
const { activeLogId = null, orderBy = 'timestamp:desc' } = params;
|
||||
|
||||
const paginateData = getPaginationQueryDataV2({
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
});
|
||||
|
||||
let updatedFilters = params.filters;
|
||||
let updatedFilterExpression = params.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilters = {
|
||||
...params.filters,
|
||||
items: [
|
||||
...(params.filters?.items || []),
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
key: 'id',
|
||||
type: '',
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: activeLogId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
// Create orderBy array based on orderDirection
|
||||
const [columnName, order] = orderBy.split(':');
|
||||
|
||||
const newOrderBy = [
|
||||
{ columnName: columnName || 'timestamp', order: order || 'desc' },
|
||||
{ columnName: 'id', order: order || 'desc' },
|
||||
];
|
||||
|
||||
queryData = [
|
||||
{
|
||||
...(getListQuery(query) || initialQueryBuilderFormValues),
|
||||
...paginateData,
|
||||
...(updatedFilters ? { filters: updatedFilters } : {}),
|
||||
filter: { expression: updatedFilterExpression || '' },
|
||||
groupBy: [],
|
||||
having: {
|
||||
expression: '',
|
||||
},
|
||||
orderBy: newOrderBy,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const data: Query = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData,
|
||||
},
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query | null,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query | null => {
|
||||
if (!query) return null;
|
||||
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const listQuery = getListQuery(query);
|
||||
if (!listQuery) return null;
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...listQuery,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
limit: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return query;
|
||||
};
|
||||
@@ -11,29 +11,29 @@ import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialFilters,
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValues,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import GoToTop from 'container/GoToTop';
|
||||
import {} from 'container/LiveLogs/constants';
|
||||
import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||
import LogsExplorerList from 'container/LogsExplorerList';
|
||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getFrequencyChartData,
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined, set } from 'lodash-es';
|
||||
import LiveLogs from 'pages/LiveLogs';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
@@ -52,10 +52,15 @@ import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -63,13 +68,14 @@ import { v4 } from 'uuid';
|
||||
import LogsActionsContainer from './LogsActionsContainer';
|
||||
|
||||
function LogsExplorerViewsContainer({
|
||||
selectedView,
|
||||
setIsLoadingQueries,
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
setWarning,
|
||||
showLiveLogs,
|
||||
handleChangeSelectedView,
|
||||
}: {
|
||||
selectedView: ExplorerViews;
|
||||
setIsLoadingQueries: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
listQueryKeyRef: MutableRefObject<any>;
|
||||
@@ -77,14 +83,19 @@ function LogsExplorerViewsContainer({
|
||||
chartQueryKeyRef: MutableRefObject<any>;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
showLiveLogs: boolean;
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
|
||||
);
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const frequencyChart = getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART);
|
||||
setShowFrequencyChart(frequencyChart === 'true');
|
||||
}, []);
|
||||
|
||||
// this is to respect the panel type present in the URL rather than defaulting it to list always.
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
|
||||
@@ -106,9 +117,14 @@ function LogsExplorerViewsContainer({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleSetConfig,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const selectedPanelType = panelType || PANEL_TYPES.LIST;
|
||||
const [selectedPanelType, setSelectedPanelType] = useState<PANEL_TYPES>(
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
// State
|
||||
const [page, setPage] = useState<number>(1);
|
||||
@@ -119,9 +135,27 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
|
||||
stagedQuery,
|
||||
]);
|
||||
const listQuery = useMemo(() => {
|
||||
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
|
||||
|
||||
return stagedQuery.builder.queryData.find((item) => !item.disabled) || null;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const isMultipleQueries = useMemo(
|
||||
() =>
|
||||
currentQuery?.builder?.queryData?.length > 1 ||
|
||||
currentQuery?.builder?.queryFormulas?.length > 0,
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
const isGroupByExist = useMemo(() => {
|
||||
const groupByCount: number = currentQuery?.builder?.queryData?.reduce<number>(
|
||||
(acc, query) => acc + query.groupBy.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return groupByCount > 0;
|
||||
}, [currentQuery]);
|
||||
|
||||
const isLimit: boolean = useMemo(() => {
|
||||
if (!listQuery) return false;
|
||||
@@ -131,9 +165,66 @@ function LogsExplorerViewsContainer({
|
||||
}, [logs.length, listQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const modifiedQuery = getFrequencyChartData(stagedQuery, activeLogId);
|
||||
if (!stagedQuery || !listQuery) {
|
||||
setListChartQuery(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedFilterExpression = listQuery.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
const modifiedQueryData: IBuilderQuery = {
|
||||
...listQuery,
|
||||
aggregateOperator: LogsAggregatorOperator.COUNT,
|
||||
groupBy: [
|
||||
{
|
||||
key: 'severity_text',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'severity_text--string----true',
|
||||
},
|
||||
],
|
||||
legend: '{{severity_text}}',
|
||||
filter: {
|
||||
...listQuery?.filter,
|
||||
expression: updatedFilterExpression || '',
|
||||
},
|
||||
...(activeLogId && {
|
||||
filters: {
|
||||
...listQuery?.filters,
|
||||
items: [
|
||||
...(listQuery?.filters?.items || []),
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
key: 'id',
|
||||
type: '',
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: activeLogId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const modifiedQuery: Query = {
|
||||
...stagedQuery,
|
||||
builder: {
|
||||
...stagedQuery.builder,
|
||||
queryData: stagedQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
...modifiedQueryData,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
setListChartQuery(modifiedQuery);
|
||||
}, [stagedQuery, activeLogId]);
|
||||
}, [stagedQuery, listQuery, activeLogId]);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
@@ -155,9 +246,7 @@ function LogsExplorerViewsContainer({
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
enabled:
|
||||
showFrequencyChart &&
|
||||
!!listChartQuery &&
|
||||
selectedPanelType === PANEL_TYPES.LIST,
|
||||
showFrequencyChart && !!listChartQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
@@ -175,7 +264,7 @@ function LogsExplorerViewsContainer({
|
||||
error,
|
||||
} = useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
panelType,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
@@ -207,13 +296,77 @@ function LogsExplorerViewsContainer({
|
||||
filters: TagFilter;
|
||||
filter: Filter;
|
||||
},
|
||||
): Query | null =>
|
||||
getQueryByPanelType(query, selectedPanelType, {
|
||||
...params,
|
||||
activeLogId,
|
||||
orderBy,
|
||||
}),
|
||||
[activeLogId, orderBy, selectedPanelType],
|
||||
): Query | null => {
|
||||
if (!query) return null;
|
||||
|
||||
const paginateData = getPaginationQueryDataV2({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
|
||||
// Add filter for activeLogId if present
|
||||
let updatedFilters = params.filters;
|
||||
let updatedFilterExpression = params.filter?.expression || '';
|
||||
if (activeLogId) {
|
||||
updatedFilters = {
|
||||
...params.filters,
|
||||
items: [
|
||||
...(params.filters?.items || []),
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
key: 'id',
|
||||
type: '',
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
op: OPERATORS['<='],
|
||||
value: activeLogId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
updatedFilterExpression = `${updatedFilterExpression} id <= '${activeLogId}'`.trim();
|
||||
}
|
||||
|
||||
// Create orderBy array based on orderDirection
|
||||
const [columnName, order] = orderBy.split(':');
|
||||
|
||||
const newOrderBy = [
|
||||
{ columnName: columnName || 'timestamp', order: order || 'desc' },
|
||||
{ columnName: 'id', order: order || 'desc' },
|
||||
];
|
||||
|
||||
const queryData: IBuilderQuery[] =
|
||||
query.builder.queryData.length > 1
|
||||
? query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
...(selectedView !== ExplorerViews.LIST ? { order: [] } : {}),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
...(listQuery || initialQueryBuilderFormValues),
|
||||
...paginateData,
|
||||
...(updatedFilters ? { filters: updatedFilters } : {}),
|
||||
filter: {
|
||||
expression: updatedFilterExpression || '',
|
||||
},
|
||||
...(selectedView === ExplorerViews.LIST
|
||||
? { order: newOrderBy, orderBy: newOrderBy }
|
||||
: { order: [] }),
|
||||
},
|
||||
];
|
||||
|
||||
const data: Query = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData,
|
||||
},
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
[activeLogId, orderBy, listQuery, selectedView],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -259,7 +412,7 @@ function LogsExplorerViewsContainer({
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
const currentData = data?.payload?.data?.newResult?.data?.result || [];
|
||||
logEvent('Logs Explorer: Page visited', {
|
||||
panelType: selectedPanelType,
|
||||
panelType,
|
||||
isEmpty: !currentData?.[0]?.list,
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
@@ -267,24 +420,31 @@ function LogsExplorerViewsContainer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload]);
|
||||
|
||||
const getUpdatedQueryForExport = useCallback((): Query => {
|
||||
const updatedQuery = cloneDeep(currentQuery);
|
||||
|
||||
set(updatedQuery, 'builder.queryData[0].pageSize', 10);
|
||||
|
||||
return updatedQuery;
|
||||
}, [currentQuery]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: Dashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !selectedPanelType) return;
|
||||
if (!dashboard || !panelType) return;
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(
|
||||
selectedPanelType,
|
||||
)
|
||||
? selectedPanelType
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(requestData, selectedPanelType);
|
||||
|
||||
if (!query) return;
|
||||
const query =
|
||||
panelType === PANEL_TYPES.LIST
|
||||
? getUpdatedQueryForExport()
|
||||
: exportDefaultQuery;
|
||||
|
||||
logEvent('Logs Explorer: Add to dashboard successful', {
|
||||
panelType: selectedPanelType,
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.data?.title,
|
||||
});
|
||||
@@ -298,9 +458,36 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[safeNavigate, requestData, selectedPanelType],
|
||||
[getUpdatedQueryForExport, exportDefaultQuery, safeNavigate, panelType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldChangeView = isMultipleQueries || isGroupByExist;
|
||||
|
||||
if (selectedPanelType === PANEL_TYPES.LIST && shouldChangeView) {
|
||||
handleExplorerTabChange(PANEL_TYPES.TIME_SERIES);
|
||||
setSelectedPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
}
|
||||
|
||||
if (panelType) {
|
||||
setSelectedPanelType(panelType);
|
||||
}
|
||||
}, [
|
||||
isMultipleQueries,
|
||||
isGroupByExist,
|
||||
selectedPanelType,
|
||||
selectedView,
|
||||
handleExplorerTabChange,
|
||||
panelType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView && selectedView === ExplorerViews.LIST && handleSetConfig) {
|
||||
handleSetConfig(defaultTo(panelTypes, PANEL_TYPES.LIST), DataSource.LOGS);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleSetConfig, panelTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentData = data?.payload?.data?.newResult?.data?.result || [];
|
||||
if (currentData.length > 0 && currentData[0].list) {
|
||||
@@ -359,17 +546,19 @@ function LogsExplorerViewsContainer({
|
||||
pageSize,
|
||||
minTime,
|
||||
activeLogId,
|
||||
selectedPanelType,
|
||||
panelType,
|
||||
selectedView,
|
||||
dispatch,
|
||||
selectedTime,
|
||||
maxTime,
|
||||
orderBy,
|
||||
selectedPanelType,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!stagedQuery) return [];
|
||||
|
||||
if (selectedPanelType === PANEL_TYPES.LIST) {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
if (listChartData && listChartData.payload.data?.result.length > 0) {
|
||||
return listChartData.payload.data.result;
|
||||
}
|
||||
@@ -389,7 +578,7 @@ function LogsExplorerViewsContainer({
|
||||
const firstPayloadQueryArray = firstPayloadQuery ? [firstPayloadQuery] : [];
|
||||
|
||||
return isGroupByExist ? data.payload.data.result : firstPayloadQueryArray;
|
||||
}, [stagedQuery, selectedPanelType, data, listChartData, listQuery]);
|
||||
}, [stagedQuery, panelType, data, listChartData, listQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -450,7 +639,7 @@ function LogsExplorerViewsContainer({
|
||||
className="logs-frequency-chart"
|
||||
isLoading={isFetchingListChartData || isLoadingListChartData}
|
||||
data={chartData}
|
||||
isLogsExplorerViews={selectedPanelType === PANEL_TYPES.LIST}
|
||||
isLogsExplorerViews={panelType === PANEL_TYPES.LIST}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,7 +695,6 @@ function LogsExplorerViewsContainer({
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQuery
|
||||
import { logsQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { fireEvent, render, RenderResult, waitFor } from 'tests/test-utils';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import LogsExplorerViews from '..';
|
||||
import {
|
||||
@@ -152,12 +152,12 @@ const renderer = (): RenderResult =>
|
||||
>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
@@ -218,12 +218,12 @@ describe('LogsExplorerViews -', () => {
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
@@ -295,12 +295,12 @@ describe('LogsExplorerViews -', () => {
|
||||
<QueryBuilderContext.Provider value={customContext as any}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={ExplorerViews.LIST}
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
@@ -323,120 +323,4 @@ describe('LogsExplorerViews -', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queries by View', () => {
|
||||
it('builds Frequency Chart query with COUNT and severity_text grouping and activeLogId bound', async () => {
|
||||
// Enable frequency chart via localstorage and provide activeLogId
|
||||
(useCopyLogLink as jest.Mock).mockReturnValue({
|
||||
activeLogId: ACTIVE_LOG_ID,
|
||||
});
|
||||
// Ensure default mock return exists
|
||||
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
|
||||
data: { payload: logsQueryRangeSuccessNewFormatResponse },
|
||||
});
|
||||
|
||||
// Render with LIST panel type so the frequency chart hook runs with TIME_SERIES
|
||||
render(
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderContext.Provider
|
||||
value={
|
||||
{ ...mockQueryBuilderContextValue, panelType: PANEL_TYPES.LIST } as any
|
||||
}
|
||||
>
|
||||
<LogsExplorerViews
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const chartCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
|
||||
(call) => call[1] === PANEL_TYPES.TIME_SERIES && call[0],
|
||||
);
|
||||
expect(chartCall).toBeDefined();
|
||||
if (chartCall) {
|
||||
const frequencyQuery = chartCall[0];
|
||||
const first = frequencyQuery.builder.queryData[0];
|
||||
// Panel type used for chart fetch
|
||||
expect(chartCall[1]).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
// Transformations
|
||||
expect(first.aggregateOperator).toBe(LogsAggregatorOperator.COUNT);
|
||||
expect(first.groupBy?.[0]?.key).toBe('severity_text');
|
||||
expect(first.legend).toBe('{{severity_text}}');
|
||||
expect(Array.isArray(first.orderBy) && first.orderBy.length === 0).toBe(
|
||||
true,
|
||||
);
|
||||
expect(first.having?.expression).toBe('');
|
||||
// activeLogId constraints
|
||||
expect(first.filter?.expression).toContain(`id <= '${ACTIVE_LOG_ID}'`);
|
||||
expect(
|
||||
first.filters?.items?.some(
|
||||
(it: any) =>
|
||||
it.key?.key === 'id' && it.op === '<=' && it.value === ACTIVE_LOG_ID,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('builds List View query with orderBy and clears groupBy/having', async () => {
|
||||
(useCopyLogLink as jest.Mock).mockReturnValue({ activeLogId: undefined });
|
||||
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
|
||||
data: { payload: logsQueryRangeSuccessNewFormatResponse },
|
||||
});
|
||||
|
||||
render(
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderContext.Provider
|
||||
value={
|
||||
{ ...mockQueryBuilderContextValue, panelType: PANEL_TYPES.LIST } as any
|
||||
}
|
||||
>
|
||||
<LogsExplorerViews
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
showLiveLogs={false}
|
||||
handleChangeSelectedView={(): void => {}}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const listCall = (useGetExplorerQueryRange as jest.Mock).mock.calls.find(
|
||||
(call) => call[1] === PANEL_TYPES.LIST && call[0],
|
||||
);
|
||||
expect(listCall).toBeDefined();
|
||||
if (listCall) {
|
||||
const listQueryArg = listCall[0];
|
||||
const first = listQueryArg.builder.queryData[0];
|
||||
expect(first.groupBy?.length ?? 0).toBe(0);
|
||||
expect(first.having?.expression).toBe('');
|
||||
// Default orderBy should be timestamp desc, then id desc
|
||||
expect(first.orderBy).toEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
{ columnName: 'id', order: 'desc' },
|
||||
]);
|
||||
// Ensure the query is enabled for fetch
|
||||
expect(first.disabled).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { Typography } from 'antd/lib';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
// import Typography from 'antd/es/typography/Typography';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { ReactNode } from 'react';
|
||||
@@ -18,15 +22,15 @@ export const getLogPanelColumnsList = (
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedLogFields?.map((field: IField) => {
|
||||
const { name } = field;
|
||||
const { name, key, displayName } = field;
|
||||
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: name,
|
||||
width: name === 'body' ? 350 : 100,
|
||||
title: displayName,
|
||||
dataIndex: key,
|
||||
key,
|
||||
width: key === LOG_FIELD_BODY_KEY ? 350 : 100,
|
||||
render: (value: ReactNode): JSX.Element => {
|
||||
if (name === 'timestamp') {
|
||||
if (key === LOG_FIELD_TIMESTAMP_KEY) {
|
||||
return (
|
||||
<Typography.Text>
|
||||
{formatTimezoneAdjustedTimestamp(value as string)}
|
||||
@@ -34,7 +38,7 @@ export const getLogPanelColumnsList = (
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'body') {
|
||||
if (key === LOG_FIELD_BODY_KEY) {
|
||||
return (
|
||||
<Typography.Paragraph ellipsis={{ rows: 1 }} data-testid={name}>
|
||||
{value}
|
||||
|
||||
@@ -115,25 +115,19 @@ describe('TopOperation API Integration', () => {
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/top_operations',
|
||||
'http://localhost/api/v1/service/top_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockTopOperationsData }),
|
||||
);
|
||||
return res(ctx.status(200), ctx.json(mockTopOperationsData));
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/service/entry_point_operations',
|
||||
'http://localhost/api/v1/service/entry_point_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: mockEntryPointData }),
|
||||
);
|
||||
return res(ctx.status(200), ctx.json({ data: mockEntryPointData }));
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -168,7 +162,6 @@ describe('TopOperation API Integration', () => {
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +195,6 @@ describe('TopOperation API Integration', () => {
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -558,8 +558,10 @@ export const getDefaultWidgetData = (
|
||||
decimalPrecision: PrecisionOptionsEnum.TWO, // default decimal precision
|
||||
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
|
||||
...field,
|
||||
key: field.key ?? '',
|
||||
type: field.fieldContext ?? '',
|
||||
dataType: field.fieldDataType ?? '',
|
||||
displayName: field.displayName || field.name,
|
||||
})),
|
||||
selectedTracesFields: defaultTraceSelectedColumns,
|
||||
});
|
||||
|
||||
@@ -42,10 +42,12 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
|
||||
</SearchIconWrapper>
|
||||
</Input.Group>
|
||||
|
||||
{config.value?.map(({ name }) => (
|
||||
<AddColumnItem direction="horizontal" key={name}>
|
||||
<Typography>{name}</Typography>
|
||||
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
|
||||
{config.value?.map((column) => (
|
||||
<AddColumnItem direction="horizontal" key={column.key}>
|
||||
<Typography>{column.displayName || column.name}</Typography>
|
||||
<DeleteOutlinedIcon
|
||||
onClick={(): void => config.onRemove(column.key || column.name)}
|
||||
/>
|
||||
</AddColumnItem>
|
||||
))}
|
||||
</AddColumnWrapper>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
|
||||
import { FontSize, OptionsQuery } from './types';
|
||||
|
||||
@@ -16,15 +20,19 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'string',
|
||||
isIndexed: false,
|
||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
||||
displayName: 'Timestamp',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'string',
|
||||
isIndexed: false,
|
||||
key: LOG_FIELD_BODY_KEY,
|
||||
displayName: 'Body',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -34,29 +42,47 @@ export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
key: 'resource.service.name:string',
|
||||
displayName: 'Service Name',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
key: 'span.name:string',
|
||||
displayName: 'Span Name',
|
||||
},
|
||||
{
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'number',
|
||||
key: 'span.duration_nano:number',
|
||||
displayName: 'Duration',
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'string',
|
||||
key: 'span.http_method:string',
|
||||
displayName: 'HTTP Method',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: '',
|
||||
fieldDataType: 'string',
|
||||
key: 'span.response_status_code:string',
|
||||
displayName: 'Status Code',
|
||||
},
|
||||
{
|
||||
name: 'timestamp',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
key: 'span.timestamp:string',
|
||||
displayName: 'Timestamp',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,11 +16,6 @@ import {
|
||||
QueryKeyRequestProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
SignalType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -35,7 +30,11 @@ import {
|
||||
OptionsMenuConfig,
|
||||
OptionsQuery,
|
||||
} from './types';
|
||||
import { getOptionsFromKeys } from './utils';
|
||||
import {
|
||||
createTelemetryFieldKey,
|
||||
getOptionsFromKeys,
|
||||
resolveColumnConflicts,
|
||||
} from './utils';
|
||||
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
@@ -104,28 +103,19 @@ const useOptionsMenu = ({
|
||||
return [];
|
||||
}
|
||||
|
||||
const attributesData = initialAttributesResult?.reduce(
|
||||
(acc: TelemetryFieldKey[], attributeResponse): TelemetryFieldKey[] => {
|
||||
const suggestions =
|
||||
Object.values(attributeResponse?.data?.data?.data?.keys || {}).flat() ||
|
||||
[];
|
||||
// Collect all suggestions from all API responses first
|
||||
const allSuggestions =
|
||||
initialAttributesResult?.flatMap((attributeResponse) =>
|
||||
Object.values(attributeResponse?.data?.data?.data?.keys || {})
|
||||
.flat()
|
||||
.map((suggestion) => createTelemetryFieldKey(suggestion)),
|
||||
) || [];
|
||||
|
||||
const mappedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(suggestion) => ({
|
||||
name: suggestion.name,
|
||||
signal: suggestion.signal as SignalType,
|
||||
fieldDataType: suggestion.fieldDataType as FieldDataType,
|
||||
fieldContext: suggestion.fieldContext as FieldContext,
|
||||
}),
|
||||
);
|
||||
|
||||
return [...acc, ...mappedSuggestions];
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Resolve conflicts and deduplicate once at the end for better performance
|
||||
const attributesData = resolveColumnConflicts(allSuggestions);
|
||||
|
||||
let initialSelected: TelemetryFieldKey[] = (initialOptions?.selectColumns
|
||||
?.map((column) => attributesData.find(({ name }) => name === column))
|
||||
?.map((column) => attributesData.find(({ key }) => key === column))
|
||||
.filter((e) => !!e) || []) as TelemetryFieldKey[];
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
@@ -133,14 +123,16 @@ const useOptionsMenu = ({
|
||||
?.map((col) => {
|
||||
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
|
||||
const metaData = defaultTraceSelectedColumns.find(
|
||||
(coln) => coln.name === col.name,
|
||||
(coln) => coln.key === col.key,
|
||||
);
|
||||
|
||||
if (metaData) {
|
||||
return {
|
||||
...metaData,
|
||||
name: metaData?.name || '',
|
||||
name: metaData.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
return col;
|
||||
})
|
||||
.filter((e) => !!e);
|
||||
@@ -187,40 +179,23 @@ const useOptionsMenu = ({
|
||||
const searchedAttributesDataList = Object.values(
|
||||
searchedAttributesDataV5?.data.data.keys || {},
|
||||
).flat();
|
||||
|
||||
if (searchedAttributesDataList.length) {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const logsSelectedColumns: TelemetryFieldKey[] = defaultLogsSelectedColumns.map(
|
||||
(e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
}),
|
||||
// Map all attributes with proper key and displayName
|
||||
const mappedAttributes = searchedAttributesDataList.map((e) =>
|
||||
createTelemetryFieldKey(e),
|
||||
);
|
||||
return [
|
||||
...logsSelectedColumns,
|
||||
...searchedAttributesDataList
|
||||
.filter((attribute) => attribute.name !== 'body')
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
})),
|
||||
];
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
return searchedAttributesDataList.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
}));
|
||||
|
||||
// Combine with default columns and resolve conflicts
|
||||
const allColumns =
|
||||
dataSource === DataSource.LOGS
|
||||
? [...defaultLogsSelectedColumns, ...mappedAttributes]
|
||||
: mappedAttributes;
|
||||
|
||||
// Resolve conflicts with deduplication
|
||||
return resolveColumnConflicts(allColumns);
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return defaultTraceSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
@@ -234,19 +209,10 @@ const useOptionsMenu = ({
|
||||
const initialOptionsQuery: OptionsQuery = useMemo(() => {
|
||||
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
}));
|
||||
defaultColumns = defaultTraceSelectedColumns;
|
||||
} else if (dataSource === DataSource.LOGS) {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
}));
|
||||
defaultColumns = defaultLogsSelectedColumns;
|
||||
}
|
||||
|
||||
const finalSelectColumns = initialOptions?.selectColumns
|
||||
@@ -261,7 +227,7 @@ const useOptionsMenu = ({
|
||||
}, [dataSource, initialOptions, initialSelectedColumns]);
|
||||
|
||||
const selectedColumnKeys = useMemo(
|
||||
() => preferences?.columns?.map(({ name }) => name) || [],
|
||||
() => preferences?.columns?.map(({ key }) => key) || [],
|
||||
[preferences?.columns],
|
||||
);
|
||||
|
||||
@@ -290,7 +256,7 @@ const useOptionsMenu = ({
|
||||
const column = [
|
||||
...searchedAttributeKeys,
|
||||
...(preferences?.columns || []),
|
||||
].find(({ name }) => name === key);
|
||||
].find((column) => column.key === key);
|
||||
|
||||
if (!column) return acc;
|
||||
return [...acc, column];
|
||||
@@ -319,7 +285,7 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
({ name }) => name !== columnKey,
|
||||
(column) => column.key !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
|
||||
@@ -1,13 +1,163 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
SignalType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Display name mapping for log fieldContext columns
|
||||
* Provides user-friendly names for standard log fields
|
||||
*/
|
||||
const LOG_FIELD_DISPLAY_NAMES: Record<string, string> = {
|
||||
body: 'Body',
|
||||
severity_number: 'Severity Number',
|
||||
severity_text: 'Severity Text',
|
||||
span_id: 'Span ID',
|
||||
trace_flags: 'Trace Flags',
|
||||
trace_id: 'Trace ID',
|
||||
scope_name: 'Scope Name',
|
||||
scope_version: 'Scope Version',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a TelemetryFieldKey with properly formatted key and displayName
|
||||
* Ensures consistent key format: fieldContext.name:fieldDataType
|
||||
* Uses display name map for log fieldContext columns
|
||||
* @param suggestion - The raw suggestion data from the API
|
||||
* @returns A TelemetryFieldKey with key and displayName fields properly set
|
||||
*/
|
||||
export function createTelemetryFieldKey(suggestion: any): TelemetryFieldKey {
|
||||
let displayName = suggestion.displayName || suggestion.name;
|
||||
|
||||
// Use mapped display name for log fieldContext columns
|
||||
// We need to check the fieldContext to avoid overriding non-log fields coming from attributes
|
||||
if (
|
||||
(suggestion.fieldContext === 'log' || suggestion.fieldContext === 'scope') &&
|
||||
LOG_FIELD_DISPLAY_NAMES[suggestion.name]
|
||||
) {
|
||||
displayName = LOG_FIELD_DISPLAY_NAMES[suggestion.name];
|
||||
}
|
||||
|
||||
return {
|
||||
name: suggestion.name,
|
||||
displayName,
|
||||
key: `${suggestion.fieldContext}.${suggestion.name}:${suggestion.fieldDataType}`,
|
||||
signal: suggestion.signal as SignalType,
|
||||
fieldDataType: suggestion.fieldDataType as FieldDataType,
|
||||
fieldContext: suggestion.fieldContext as FieldContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a suffix for a column display name based on conflicts
|
||||
* @param column - The column to generate suffix for
|
||||
* @param hasContextConflict - Whether there's a conflict in fieldContext
|
||||
* @param hasDataTypeConflict - Whether there's a conflict in fieldDataType
|
||||
* @returns The suffix string to append to the column name
|
||||
*/
|
||||
function generateColumnSuffix(
|
||||
column: TelemetryFieldKey,
|
||||
hasContextConflict: boolean,
|
||||
hasDataTypeConflict: boolean,
|
||||
): string {
|
||||
if (hasContextConflict && column.fieldContext) {
|
||||
return ` (${column.fieldContext})`;
|
||||
}
|
||||
if (!hasContextConflict && hasDataTypeConflict && column.fieldDataType) {
|
||||
return ` (${column.fieldDataType})`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates display names for conflicting columns in the columnsByKey map
|
||||
* @param columns - Array of columns with the same name
|
||||
* @param columnsByKey - Map to update with new display names
|
||||
*/
|
||||
function updateConflictingDisplayNames(
|
||||
columns: TelemetryFieldKey[],
|
||||
columnsByKey: Map<string, TelemetryFieldKey>,
|
||||
): void {
|
||||
const contexts = new Set(columns.map((c) => c.fieldContext));
|
||||
const dataTypes = new Set(columns.map((c) => c.fieldDataType));
|
||||
const hasContextConflict = contexts.size > 1;
|
||||
const hasDataTypeConflict = dataTypes.size > 1;
|
||||
|
||||
if (!hasContextConflict && !hasDataTypeConflict) {
|
||||
return;
|
||||
}
|
||||
|
||||
columns.forEach((column) => {
|
||||
// Skip if already has a custom displayName (not just the name)
|
||||
if (column.displayName && column.displayName !== column.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = generateColumnSuffix(
|
||||
column,
|
||||
hasContextConflict,
|
||||
hasDataTypeConflict,
|
||||
);
|
||||
|
||||
if (suffix) {
|
||||
columnsByKey.set(column.key || column.name, {
|
||||
...column,
|
||||
displayName: `${column.name}${suffix}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a list of TelemetryFieldKeys and updates displayName for conflicting columns
|
||||
* Adds suffix with fieldContext and/or fieldDataType when columns have the same name
|
||||
* but different context or data type.
|
||||
* Note: 'log' & 'scope' fieldContext suffix is hidden as it's the default context for logs.
|
||||
* Also deduplicates columns with the same key.
|
||||
* @param suggestions - Array of TelemetryFieldKey objects to process
|
||||
* @returns Array with updated displayNames for conflicting columns and no duplicates
|
||||
*/
|
||||
export function resolveColumnConflicts(
|
||||
suggestions: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
// Use Map for O(1) deduplication by key
|
||||
const columnsByKey = new Map<string, TelemetryFieldKey>();
|
||||
// Track columns by name to detect conflicts
|
||||
const columnsByName = new Map<string, TelemetryFieldKey[]>();
|
||||
|
||||
// First pass: deduplicate by key and group by name
|
||||
suggestions.forEach((suggestion) => {
|
||||
// Skip duplicates (same key)
|
||||
if (columnsByKey.has(suggestion.key || suggestion.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
columnsByKey.set(suggestion.key || suggestion.name, suggestion);
|
||||
|
||||
// Group by name for conflict detection
|
||||
const existing = columnsByName.get(suggestion.name) || [];
|
||||
columnsByName.set(suggestion.name, [...existing, suggestion]);
|
||||
});
|
||||
|
||||
// Second pass: resolve conflicts for columns with same name
|
||||
columnsByName.forEach((columns) => {
|
||||
if (columns.length > 1) {
|
||||
updateConflictingDisplayNames(columns, columnsByKey);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(columnsByKey.values());
|
||||
}
|
||||
|
||||
export const getOptionsFromKeys = (
|
||||
keys: TelemetryFieldKey[],
|
||||
selectedKeys: (string | undefined)[],
|
||||
): SelectProps['options'] => {
|
||||
const options = keys.map(({ name }) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
const options = keys.map(({ key, displayName, name }) => ({
|
||||
label: displayName || name,
|
||||
value: key,
|
||||
}));
|
||||
|
||||
return options.filter(
|
||||
|
||||
@@ -41,7 +41,6 @@ function TablePanelWrapper({
|
||||
panelType={widget.panelTypes}
|
||||
queryRangeRequest={queryRangeRequest}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
hiddenColumns={widget.hiddenColumns}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...GRID_TABLE_CONFIG}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ export type QueryTableProps = Omit<
|
||||
query: Query;
|
||||
renderActionCell?: (record: RowData) => ReactNode;
|
||||
modifyColumns?: (columns: ColumnsType<RowData>) => ColumnsType<RowData>;
|
||||
renderColumnCell?: Record<string, (...args: any[]) => ReactNode>;
|
||||
renderColumnCell?: Record<string, (record: RowData) => ReactNode>;
|
||||
downloadOption?: DownloadOptions;
|
||||
columns?: ColumnsType<RowData>;
|
||||
dataSource?: RowData[];
|
||||
|
||||
@@ -57,7 +57,7 @@ function ResourceAttributesFilter(): JSX.Element | null {
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
operatorConfigKey={OperatorConfigKeys.EXCEPTIONS}
|
||||
hideSpanScopeSelector
|
||||
hideSpanScopeSelector={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,10 @@ import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import {
|
||||
LOG_FIELD_BODY_KEY,
|
||||
LOG_FIELD_TIMESTAMP_KEY,
|
||||
} from 'lib/logs/flatLogData';
|
||||
import { Compass } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
@@ -182,12 +186,16 @@ function SpanLogs({
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
displayName: 'Body',
|
||||
name: 'body',
|
||||
key: LOG_FIELD_BODY_KEY,
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
||||
displayName: 'Timestamp',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -245,81 +245,5 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset operators when going from gauge -> empty -> gauge', () => {
|
||||
// Start with a gauge metric
|
||||
const gaugeQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: {
|
||||
key: 'original_gauge',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
} as BaseAutocompleteData,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.COUNT_DISTINCT,
|
||||
metricName: 'original_gauge',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ query }) =>
|
||||
useQueryOperations({
|
||||
query,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
}),
|
||||
{
|
||||
initialProps: { query: gaugeQuery },
|
||||
},
|
||||
);
|
||||
|
||||
// Re-render with empty attribute
|
||||
const emptyAttribute: BaseAutocompleteData = {
|
||||
key: '',
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
const emptyQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: emptyAttribute,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.COUNT,
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender({ query: emptyQuery });
|
||||
|
||||
// Change to a new gauge metric
|
||||
const newGaugeAttribute: BaseAutocompleteData = {
|
||||
key: 'new_gauge',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
};
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newGaugeAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregateAttribute: newGaugeAttribute,
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
metricName: 'new_gauge',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,8 +89,6 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
name: metricName,
|
||||
type: metricType,
|
||||
});
|
||||
} else {
|
||||
setPreviousMetricInfo(null);
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
@@ -297,6 +295,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
|
||||
if (!isEditMode) {
|
||||
// Get current metric info
|
||||
const currentMetricName = newQuery.aggregateAttribute?.key || '';
|
||||
const currentMetricType = newQuery.aggregateAttribute?.type || '';
|
||||
|
||||
const prevMetricType = previousMetricInfo?.type
|
||||
@@ -379,6 +378,14 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tracked metric info for next comparison only if we have valid data
|
||||
if (currentMetricName && currentMetricType) {
|
||||
setPreviousMetricInfo({
|
||||
name: currentMetricName,
|
||||
type: currentMetricType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { useGetSearchQueryParam } from './queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
|
||||
|
||||
export interface ICurrentQueryData {
|
||||
name: string;
|
||||
id: string;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
export const useHandleExplorerTabChange = (): {
|
||||
handleExplorerTabChange: (
|
||||
type: string,
|
||||
@@ -93,3 +87,9 @@ export const useHandleExplorerTabChange = (): {
|
||||
|
||||
return { handleExplorerTabChange };
|
||||
};
|
||||
|
||||
interface ICurrentQueryData {
|
||||
name: string;
|
||||
id: string;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,61 @@
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
// Exported constants for top-level field mappings
|
||||
export const LOG_FIELD_ID_KEY = 'id';
|
||||
export const LOG_FIELD_TIMESTAMP_KEY = 'log.timestamp:string';
|
||||
export const LOG_FIELD_BODY_KEY = 'log.body:string';
|
||||
export const LOG_FIELD_SPAN_ID_KEY = 'log.span_id:string';
|
||||
export const LOG_FIELD_TRACE_ID_KEY = 'log.trace_id:string';
|
||||
export const LOG_FIELD_TRACE_FLAGS_KEY = 'log.trace_flags:number';
|
||||
export const LOG_FIELD_SEVERITY_TEXT_KEY = 'log.severity_text:string';
|
||||
export const LOG_FIELD_SEVERITY_NUMBER_KEY = 'log.severity_number:number';
|
||||
export const LOG_FIELD_SCOPE_NAME_KEY = 'scope.scope_name:string';
|
||||
export const LOG_FIELD_SCOPE_VERSION_KEY = 'scope.scope_version:string';
|
||||
|
||||
export function FlatLogData(log: ILog): Record<string, string> {
|
||||
const flattenLogObject: Record<string, string> = {};
|
||||
|
||||
Object.keys(log).forEach((key: string): void => {
|
||||
if (typeof log[key as never] !== 'object') {
|
||||
flattenLogObject[key] = log[key as never];
|
||||
} else {
|
||||
Object.keys(defaultTo(log[key as never], {})).forEach((childKey) => {
|
||||
flattenLogObject[childKey] = log[key as never][childKey];
|
||||
// Map of field names to their contexts and data types
|
||||
const fieldMappings: Record<string, { context: string; datatype: string }> = {
|
||||
resources_string: { context: 'resource', datatype: 'string' },
|
||||
scope_string: { context: 'scope', datatype: 'string' },
|
||||
attributes_string: { context: 'attribute', datatype: 'string' },
|
||||
attributes_number: { context: 'attribute', datatype: 'number' },
|
||||
attributes_bool: { context: 'attribute', datatype: 'bool' },
|
||||
};
|
||||
|
||||
// Flatten specific fields with context and datatype
|
||||
Object.entries(fieldMappings).forEach(([fieldKey, { context, datatype }]) => {
|
||||
const fieldData = log[fieldKey as keyof ILog];
|
||||
if (fieldData && typeof fieldData === 'object') {
|
||||
Object.entries(defaultTo(fieldData, {})).forEach(([key, value]) => {
|
||||
const flatKey = `${context}.${key}:${datatype}`;
|
||||
flattenLogObject[flatKey] = String(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add top-level fields
|
||||
const topLevelFieldsToContextMapping: Record<string, string> = {
|
||||
id: LOG_FIELD_ID_KEY,
|
||||
timestamp: LOG_FIELD_TIMESTAMP_KEY,
|
||||
body: LOG_FIELD_BODY_KEY,
|
||||
span_id: LOG_FIELD_SPAN_ID_KEY,
|
||||
trace_id: LOG_FIELD_TRACE_ID_KEY,
|
||||
trace_flags: LOG_FIELD_TRACE_FLAGS_KEY,
|
||||
severity_text: LOG_FIELD_SEVERITY_TEXT_KEY,
|
||||
severity_number: LOG_FIELD_SEVERITY_NUMBER_KEY,
|
||||
scope_name: LOG_FIELD_SCOPE_NAME_KEY,
|
||||
scope_version: LOG_FIELD_SCOPE_VERSION_KEY,
|
||||
};
|
||||
|
||||
Object.entries(topLevelFieldsToContextMapping).forEach(([field, key]) => {
|
||||
const value = log[field as keyof ILog];
|
||||
if (value !== undefined && value !== null) {
|
||||
flattenLogObject[key] = String(value);
|
||||
}
|
||||
});
|
||||
|
||||
return flattenLogObject;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,8 @@ export const handlers = [
|
||||
res(ctx.status(200), ctx.json(queryRangeSuccessResponse)),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/services', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: serviceSuccessResponse }),
|
||||
),
|
||||
rest.post('http://localhost/api/v1/services', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(serviceSuccessResponse)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
|
||||
@@ -10,7 +10,8 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
|
||||
import LogsExplorerViewsContainer from 'container/LogsExplorerViews';
|
||||
import {
|
||||
@@ -24,33 +25,34 @@ import RightToolbarActions from 'container/QueryBuilder/components/ToolbarAction
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { defaultTo, isEmpty, isEqual, isNull } from 'lodash-es';
|
||||
import { isEmpty, isEqual, isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { EventSourceProvider } from 'providers/EventSource';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
panelTypeToExplorerView,
|
||||
getExplorerViewForPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
|
||||
import { ExplorerViews } from './utils';
|
||||
|
||||
function LogsExplorer(): JSX.Element {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [showLiveLogs, setShowLiveLogs] = useState<boolean>(false);
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(
|
||||
() => panelTypeToExplorerView[panelTypesFromUrl],
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
const { logs } = usePreferenceContext();
|
||||
const { preferences } = logs;
|
||||
@@ -65,7 +67,30 @@ function LogsExplorer(): JSX.Element {
|
||||
return true;
|
||||
});
|
||||
|
||||
const { handleRunQuery, handleSetConfig } = useQueryBuilder();
|
||||
// Update selected view when panel type from URL changes
|
||||
useEffect(() => {
|
||||
if (panelTypesFromUrl) {
|
||||
const newView = getExplorerViewForPanelType(panelTypesFromUrl);
|
||||
if (newView && newView !== selectedView) {
|
||||
setSelectedView(newView);
|
||||
}
|
||||
}
|
||||
}, [panelTypesFromUrl, selectedView]);
|
||||
|
||||
// Update URL when selectedView changes (without triggering re-renders)
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(QueryParams.selectedExplorerView, selectedView);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}, [selectedView]);
|
||||
|
||||
const {
|
||||
handleRunQuery,
|
||||
handleSetConfig,
|
||||
updateAllQueriesOperators,
|
||||
currentQuery,
|
||||
updateQueriesData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -77,13 +102,50 @@ function LogsExplorer(): JSX.Element {
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>(undefined);
|
||||
|
||||
const [shouldReset, setShouldReset] = useState(false);
|
||||
|
||||
const [defaultQuery, setDefaultQuery] = useState<Query>(() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
),
|
||||
);
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(
|
||||
defaultTo(explorerViewToPanelType[view], PANEL_TYPES.LIST),
|
||||
(view: ExplorerViews): void => {
|
||||
if (selectedView === ExplorerViews.LIST) {
|
||||
handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS);
|
||||
}
|
||||
|
||||
if (view === ExplorerViews.LIST) {
|
||||
if (
|
||||
selectedView !== ExplorerViews.LIST &&
|
||||
currentQuery?.builder?.queryData?.[0]
|
||||
) {
|
||||
const filterToRetain = currentQuery.builder.queryData[0].filter;
|
||||
|
||||
const newDefaultQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
const newListQuery = updateQueriesData(
|
||||
newDefaultQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
if (index === 0) {
|
||||
return { ...item, filter: filterToRetain };
|
||||
}
|
||||
return item;
|
||||
},
|
||||
);
|
||||
setDefaultQuery(newListQuery);
|
||||
}
|
||||
setShouldReset(true);
|
||||
}
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
if (view !== ExplorerViews.LIST) {
|
||||
@@ -91,13 +153,38 @@ function LogsExplorer(): JSX.Element {
|
||||
}
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
view === ExplorerViews.TIMESERIES ? PANEL_TYPES.TIME_SERIES : view,
|
||||
);
|
||||
},
|
||||
[handleSetConfig, handleExplorerTabChange, setSelectedView],
|
||||
[
|
||||
handleSetConfig,
|
||||
handleExplorerTabChange,
|
||||
selectedView,
|
||||
currentQuery,
|
||||
updateAllQueriesOperators,
|
||||
updateQueriesData,
|
||||
setSelectedView,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({
|
||||
defaultValue: defaultQuery,
|
||||
forceReset: shouldReset,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldReset) {
|
||||
setShouldReset(false);
|
||||
setDefaultQuery(
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [shouldReset, updateAllQueriesOperators]);
|
||||
|
||||
const handleFilterVisibilityChange = (): void => {
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS,
|
||||
@@ -312,12 +399,12 @@ function LogsExplorer(): JSX.Element {
|
||||
</div>
|
||||
<div className="logs-explorer-views">
|
||||
<LogsExplorerViewsContainer
|
||||
selectedView={selectedView}
|
||||
listQueryKeyRef={listQueryKeyRef}
|
||||
chartQueryKeyRef={chartQueryKeyRef}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
setWarning={setWarning}
|
||||
showLiveLogs={showLiveLogs}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ function MetricsApplication(): JSX.Element {
|
||||
servicename: string;
|
||||
}>();
|
||||
|
||||
const servicename = decodeURIComponent(encodedServiceName);
|
||||
|
||||
const activeKey = useMetricsApplicationTabKey();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
@@ -44,7 +46,7 @@ function MetricsApplication(): JSX.Element {
|
||||
|
||||
const onTabChange = (tab: string): void => {
|
||||
urlQuery.set(QueryParams.tab, tab);
|
||||
safeNavigate(`/services/${encodedServiceName}?${urlQuery.toString()}`);
|
||||
safeNavigate(`/services/${servicename}?${urlQuery.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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, PencilLine } from 'lucide-react';
|
||||
import { ChevronDown, HardHat, PencilLine } from 'lucide-react';
|
||||
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -194,6 +194,7 @@ 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,17 +1,12 @@
|
||||
:root {
|
||||
--bg-vanilla-100-rgb: 255, 255, 255;
|
||||
}
|
||||
.funnel-table {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
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;
|
||||
@@ -102,7 +97,7 @@
|
||||
}
|
||||
|
||||
.table-row-dark {
|
||||
background: rgba(var(--bg-vanilla-100-rgb), 0.01);
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.trace-id-cell {
|
||||
|
||||
@@ -16,6 +16,6 @@ export const topTracesTableColumns = [
|
||||
title: 'STEP TRANSITION DURATION',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
render: (value: string): string => getYAxisFormattedValue(`${value}`, 'ms'),
|
||||
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,7 +136,6 @@ export interface Widgets extends IBaseWidget {
|
||||
query: Query;
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||
customColTitles?: Record<string, string>;
|
||||
hiddenColumns?: string[];
|
||||
}
|
||||
|
||||
export interface PromQLWidgets extends IBaseWidget {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface IField {
|
||||
name: string;
|
||||
displayName: string;
|
||||
key: string;
|
||||
type: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,11 @@ export interface VariableItem {
|
||||
|
||||
export interface TelemetryFieldKey {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
// display name can change dynamically depending on if there's a conflicting field with same name and in only meant for UI display
|
||||
key?: string;
|
||||
// key is a unique identifier generated for each field, used for comparisons and selections
|
||||
// key = fieldContext.name:fieldDataType
|
||||
description?: string;
|
||||
unit?: string;
|
||||
signal?: SignalType;
|
||||
|
||||
@@ -26,16 +26,7 @@ describe('extractQueryPairs', () => {
|
||||
valuesPosition: [],
|
||||
hasNegation: true,
|
||||
isMultiValue: false,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 5,
|
||||
negationEnd: 9,
|
||||
negationStart: 7,
|
||||
operatorEnd: 16,
|
||||
operatorStart: 11,
|
||||
valueEnd: undefined,
|
||||
valueStart: undefined,
|
||||
},
|
||||
position: expect.any(Object),
|
||||
isComplete: false,
|
||||
},
|
||||
{
|
||||
@@ -46,16 +37,7 @@ describe('extractQueryPairs', () => {
|
||||
valuesPosition: [],
|
||||
hasNegation: true,
|
||||
isMultiValue: false,
|
||||
position: {
|
||||
keyEnd: 25,
|
||||
keyStart: 22,
|
||||
negationEnd: 29,
|
||||
negationStart: 27,
|
||||
operatorEnd: 34,
|
||||
operatorStart: 31,
|
||||
valueEnd: 42,
|
||||
valueStart: 36,
|
||||
},
|
||||
position: expect.any(Object),
|
||||
isComplete: true,
|
||||
},
|
||||
]);
|
||||
@@ -72,11 +54,12 @@ describe('extractQueryPairs', () => {
|
||||
isComplete: true,
|
||||
value: expect.stringMatching(/^\(.*\)$/),
|
||||
valueList: ['1', '2', '3'],
|
||||
valuesPosition: [
|
||||
{ start: 7, end: 7 },
|
||||
{ start: 10, end: 10 },
|
||||
{ start: 13, end: 13 },
|
||||
],
|
||||
valuesPosition: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
start: expect.any(Number),
|
||||
end: expect.any(Number),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -92,31 +75,6 @@ describe('extractQueryPairs', () => {
|
||||
isComplete: true,
|
||||
value: expect.stringMatching(/^\[.*\]$/),
|
||||
valueList: ["'a'", "'b'", "'c'"],
|
||||
valuesPosition: [
|
||||
{ start: 11, end: 13 },
|
||||
{ start: 18, end: 20 },
|
||||
{ start: 25, end: 27 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should extract correct query pairs when the query has space at the start of the value', () => {
|
||||
const input = " label IN [ 'a' , 'b' , 'c' ]";
|
||||
const result = extractQueryPairs(input);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'label',
|
||||
operator: 'IN',
|
||||
isMultiValue: true,
|
||||
isComplete: true,
|
||||
value: expect.stringMatching(/^\[.*\]$/),
|
||||
valueList: ["'a'", "'b'", "'c'"],
|
||||
valuesPosition: [
|
||||
{ start: 13, end: 15 },
|
||||
{ start: 20, end: 22 },
|
||||
{ start: 27, end: 29 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -15,13 +15,6 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST,
|
||||
};
|
||||
|
||||
export const explorerViewToPanelType = {
|
||||
[ExplorerViews.LIST]: PANEL_TYPES.LIST,
|
||||
[ExplorerViews.TIMESERIES]: PANEL_TYPES.TIME_SERIES,
|
||||
[ExplorerViews.TRACE]: PANEL_TYPES.TRACE,
|
||||
[ExplorerViews.TABLE]: PANEL_TYPES.TABLE,
|
||||
} as Record<ExplorerViews, PANEL_TYPES>;
|
||||
|
||||
/**
|
||||
* Get the explorer view based on panel type from URL or saved view
|
||||
* @param searchParams - URL search parameters
|
||||
|
||||
@@ -13832,6 +13832,11 @@ ora@^5.4.1:
|
||||
strip-ansi "^6.0.0"
|
||||
wcwidth "^1.0.1"
|
||||
|
||||
os-tmpdir@~1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
|
||||
|
||||
outvariant@^1.2.1, outvariant@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.0.tgz#e742e4bda77692da3eca698ef5bfac62d9fba06e"
|
||||
@@ -17320,10 +17325,12 @@ tinycolor2@1, tinycolor2@1.6.0, tinycolor2@^1.6.0:
|
||||
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
|
||||
|
||||
tmp@0.2.4, tmp@^0.0.33:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.4.tgz#c6db987a2ccc97f812f17137b36af2b6521b0d13"
|
||||
integrity sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==
|
||||
tmp@^0.0.33:
|
||||
version "0.0.33"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||
integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
|
||||
dependencies:
|
||||
os-tmpdir "~1.0.2"
|
||||
|
||||
tmpl@1.0.5:
|
||||
version "1.0.5"
|
||||
|
||||
2
go.mod
2
go.mod
@@ -9,11 +9,11 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
github.com/SigNoz/signoz-otel-collector v0.129.4
|
||||
github.com/allegro/bigcache/v3 v3.1.0
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
|
||||
6
go.sum
6
go.sum
@@ -118,6 +118,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk=
|
||||
github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
@@ -209,10 +211,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE=
|
||||
github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/digitalocean/godo v1.144.0 h1:rDCsmpwcDe5egFQ3Ae45HTde685/GzX037mWRMPufW0=
|
||||
|
||||
@@ -18,8 +18,6 @@ type AuthZ interface {
|
||||
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
|
||||
CheckWithTupleCreation(context.Context, authtypes.Claims, valuer.UUID, authtypes.Relation, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) error
|
||||
|
||||
CheckWithTupleCreationWithoutClaims(context.Context, valuer.UUID, authtypes.Relation, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) 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
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package openfgaauthz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
authz "github.com/SigNoz/signoz/pkg/authz"
|
||||
@@ -95,153 +94,6 @@ func (provider *provider) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKey) error {
|
||||
storeID, modelID := provider.getStoreIDandModelID()
|
||||
checkResponse, err := provider.openfgaServer.Check(
|
||||
ctx,
|
||||
&openfgav1.CheckRequest{
|
||||
StoreId: storeID,
|
||||
AuthorizationModelId: modelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: tupleReq.User,
|
||||
Relation: tupleReq.Relation,
|
||||
Object: tupleReq.Object,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !checkResponse.Allowed {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", tupleReq.User, tupleReq.Relation, tupleReq.Object)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.TupleKey) error {
|
||||
storeID, modelID := provider.getStoreIDandModelID()
|
||||
batchCheckItems := make([]*openfgav1.BatchCheckItem, 0)
|
||||
for idx, tuple := range tupleReq {
|
||||
batchCheckItems = append(batchCheckItems, &openfgav1.BatchCheckItem{
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
},
|
||||
// the batch check response is map[string] keyed by correlationID.
|
||||
CorrelationId: strconv.Itoa(idx),
|
||||
})
|
||||
}
|
||||
|
||||
checkResponse, err := provider.openfgaServer.BatchCheck(
|
||||
ctx,
|
||||
&openfgav1.BatchCheckRequest{
|
||||
StoreId: storeID,
|
||||
AuthorizationModelId: modelID,
|
||||
Checks: batchCheckItems,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
for _, checkResponse := range checkResponse.Result {
|
||||
if checkResponse.GetAllowed() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "")
|
||||
|
||||
}
|
||||
|
||||
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.TypeableUser, claims.UserID, orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples, err := authtypes.TypeableOrganization.Tuples(subject, translation, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, orgID.StringValue())}, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.BatchCheck(ctx, tuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector) error {
|
||||
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples, err := authtypes.TypeableOrganization.Tuples(subject, translation, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, orgID.StringValue())}, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.BatchCheck(ctx, tuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
|
||||
storeID, modelID := provider.getStoreIDandModelID()
|
||||
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: storeID,
|
||||
AuthorizationModelId: modelID,
|
||||
Writes: func() *openfgav1.WriteRequestWrites {
|
||||
if len(additions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: additions,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}(),
|
||||
Deletes: func() *openfgav1.WriteRequestDeletes {
|
||||
if len(deletionTuplesWithoutCondition) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deletionTuplesWithoutCondition,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}(),
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
storeID, modelID := provider.getStoreIDandModelID()
|
||||
response, err := provider.openfgaServer.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: storeID,
|
||||
AuthorizationModelId: modelID,
|
||||
User: subject,
|
||||
Relation: relation.StringValue(),
|
||||
Type: typeable.Type().StringValue(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), typeable.Type().StringValue())
|
||||
}
|
||||
|
||||
return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
|
||||
}
|
||||
|
||||
func (provider *provider) getOrCreateStore(ctx context.Context, name string) (string, error) {
|
||||
stores, err := provider.openfgaServer.ListStores(ctx, &openfgav1.ListStoresRequest{})
|
||||
if err != nil {
|
||||
@@ -324,12 +176,112 @@ func (provider *provider) isModelEqual(expected *openfgav1.AuthorizationModel, a
|
||||
|
||||
}
|
||||
|
||||
func (provider *provider) getStoreIDandModelID() (string, string) {
|
||||
provider.mtx.RLock()
|
||||
defer provider.mtx.RUnlock()
|
||||
|
||||
storeID := provider.storeID
|
||||
modelID := provider.modelID
|
||||
|
||||
return storeID, modelID
|
||||
func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKey) error {
|
||||
checkResponse, err := provider.openfgaServer.Check(
|
||||
ctx,
|
||||
&openfgav1.CheckRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: tupleReq.User,
|
||||
Relation: tupleReq.Relation,
|
||||
Object: tupleReq.Object,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !checkResponse.Allowed {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", tupleReq.User, tupleReq.Relation, tupleReq.Object)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.TupleKey) error {
|
||||
batchCheckItems := make([]*openfgav1.BatchCheckItem, 0)
|
||||
for _, tuple := range tupleReq {
|
||||
batchCheckItems = append(batchCheckItems, &openfgav1.BatchCheckItem{
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: tuple.User,
|
||||
Relation: tuple.Relation,
|
||||
Object: tuple.Object,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
checkResponse, err := provider.openfgaServer.BatchCheck(
|
||||
ctx,
|
||||
&openfgav1.BatchCheckRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
Checks: batchCheckItems,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
for _, checkResponse := range checkResponse.Result {
|
||||
if checkResponse.GetAllowed() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "")
|
||||
|
||||
}
|
||||
|
||||
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 := authtypes.TypeableOrganization.Tuples(subject, translation, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, orgID.StringValue())}, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.BatchCheck(ctx, tuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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: &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: additions,
|
||||
},
|
||||
Deletes: &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deletionTuplesWithoutCondition,
|
||||
},
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
|
||||
response, err := provider.openfgaServer.ListObjects(ctx, &openfgav1.ListObjectsRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
User: subject,
|
||||
Relation: relation.StringValue(),
|
||||
Type: typeable.Type().StringValue(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), typeable.Type().StringValue())
|
||||
}
|
||||
|
||||
return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user