mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-21 15:50:27 +01:00
Compare commits
1 Commits
v0.102.0
...
fix/pass_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5165d1665 |
@@ -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
|
||||
|
||||
12
Makefile
12
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
|
||||
@@ -206,4 +208,4 @@ py-lint: ## Run lint for integration tests
|
||||
|
||||
.PHONY: py-test
|
||||
py-test: ## Runs integration tests
|
||||
@cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/
|
||||
@cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
get: jest.fn(),
|
||||
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', () => ({
|
||||
get: jest.fn(),
|
||||
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`, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
tags: props.selectedTags,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
const response = await axios.post(`/services`, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
tags: props.selectedTags,
|
||||
});
|
||||
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 endpoint = props.isEntryPoint
|
||||
? '/service/entry_point_operations'
|
||||
: '/service/top_operations';
|
||||
|
||||
const response = await ApiV2Instance.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
limit: 5000,
|
||||
});
|
||||
const response = await axios.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
});
|
||||
|
||||
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`, {
|
||||
start,
|
||||
end,
|
||||
show_ip: showIp,
|
||||
filter,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||
payload,
|
||||
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
|
||||
version,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, version),
|
||||
enabled: !!payload,
|
||||
};
|
||||
}),
|
||||
endPointDetailsQueryPayload.map((payload, index) => ({
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||
payload,
|
||||
filters?.items, // Include filters.items in queryKey for better caching
|
||||
ENTITY_VERSION_V4,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
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,27 +143,23 @@ function DomainMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={formattedDomainMetricsData.errorRate}>
|
||||
{formattedDomainMetricsData.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||
)}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||
)}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||
);
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
);
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
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,25 +88,21 @@ function EndPointMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.errorRate}>
|
||||
{metricsData?.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(metricsData?.errorRate ?? 0).toFixed(2),
|
||||
);
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(metricsData?.errorRate ?? 0).toFixed(2),
|
||||
);
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
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,
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -17,6 +17,12 @@ export const Card = styled(CardComponent)<CardProps>`
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0) 0%,
|
||||
rgba(171, 189, 255, 0) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
|
||||
${({ isDarkMode }): StyledCSS =>
|
||||
!isDarkMode &&
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -90,9 +90,8 @@ export function QueryTable({
|
||||
column: any,
|
||||
tableColumns: any,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
if (isQueryTypeBuilder && enableDrillDown) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ function ResourceAttributesFilter(): JSX.Element | null {
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
operatorConfigKey={OperatorConfigKeys.EXCEPTIONS}
|
||||
hideSpanScopeSelector
|
||||
hideSpanScopeSelector={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,20 +16,8 @@
|
||||
|
||||
// https://tobyzerner.github.io/placement.js/dist/index.js
|
||||
|
||||
/**
|
||||
* Positions an element (tooltip/popover) relative to a reference element.
|
||||
* Automatically flips to the opposite side if there's insufficient space.
|
||||
*
|
||||
* @param element - The HTMLElement to position
|
||||
* @param reference - Reference element/Range or bounding rect
|
||||
* @param side - Preferred side: 'top', 'bottom', 'left', 'right' (default: 'bottom')
|
||||
* @param align - Alignment: 'start', 'center', 'end' (default: 'center')
|
||||
* @param options - Optional bounds for constraining the element
|
||||
* - bound: Custom boundary rect/element
|
||||
* - followCursor: { x, y } - If provided, tooltip follows cursor with smart positioning
|
||||
*/
|
||||
export const placement = (function () {
|
||||
const AXIS_PROPS = {
|
||||
const e = {
|
||||
size: ['height', 'width'],
|
||||
clientSize: ['clientHeight', 'clientWidth'],
|
||||
offsetSize: ['offsetHeight', 'offsetWidth'],
|
||||
@@ -40,241 +28,87 @@ export const placement = (function () {
|
||||
marginAfter: ['marginBottom', 'marginRight'],
|
||||
scrollOffset: ['pageYOffset', 'pageXOffset'],
|
||||
};
|
||||
|
||||
function extractRect(source) {
|
||||
return {
|
||||
top: source.top,
|
||||
bottom: source.bottom,
|
||||
left: source.left,
|
||||
right: source.right,
|
||||
};
|
||||
function t(e) {
|
||||
return { top: e.top, bottom: e.bottom, left: e.left, right: e.right };
|
||||
}
|
||||
|
||||
return function (element, reference, side, align, options) {
|
||||
// Default parameters
|
||||
void 0 === side && (side = 'bottom');
|
||||
void 0 === align && (align = 'center');
|
||||
void 0 === options && (options = {});
|
||||
|
||||
// Handle cursor following mode
|
||||
if (options.followCursor) {
|
||||
const cursorX = options.followCursor.x;
|
||||
const cursorY = options.followCursor.y;
|
||||
const offset = options.followCursor.offset || 10; // Default 10px offset from cursor
|
||||
|
||||
element.style.position = 'absolute';
|
||||
element.style.maxWidth = '';
|
||||
element.style.maxHeight = '';
|
||||
|
||||
const elementWidth = element.offsetWidth;
|
||||
const elementHeight = element.offsetHeight;
|
||||
|
||||
// Use viewport bounds for cursor following (not chart bounds)
|
||||
const viewportBounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: window.innerHeight,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
|
||||
// Vertical positioning: follow cursor Y with offset, clamped to viewport
|
||||
const topPosition = cursorY + offset;
|
||||
const clampedTop = Math.max(
|
||||
viewportBounds.top,
|
||||
Math.min(topPosition, viewportBounds.bottom - elementHeight),
|
||||
);
|
||||
element.style.top = `${clampedTop}px`;
|
||||
element.style.bottom = 'auto';
|
||||
|
||||
// Horizontal positioning: auto-detect left or right based on available space
|
||||
const spaceOnRight = viewportBounds.right - cursorX;
|
||||
const spaceOnLeft = cursorX - viewportBounds.left;
|
||||
|
||||
if (spaceOnRight >= elementWidth + offset) {
|
||||
// Enough space on the right
|
||||
element.style.left = `${cursorX + offset}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'right';
|
||||
} else if (spaceOnLeft >= elementWidth + offset) {
|
||||
// Not enough space on right, use left
|
||||
element.style.left = `${cursorX - elementWidth - offset}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'left';
|
||||
} else if (spaceOnRight > spaceOnLeft) {
|
||||
// Not enough space on either side, pick the side with more space
|
||||
const leftPos = cursorX + offset;
|
||||
const clampedLeft = Math.max(
|
||||
viewportBounds.left,
|
||||
Math.min(leftPos, viewportBounds.right - elementWidth),
|
||||
);
|
||||
element.style.left = `${clampedLeft}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'right';
|
||||
} else {
|
||||
const leftPos = cursorX - elementWidth - offset;
|
||||
const clampedLeft = Math.max(
|
||||
viewportBounds.left,
|
||||
Math.min(leftPos, viewportBounds.right - elementWidth),
|
||||
);
|
||||
element.style.left = `${clampedLeft}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'left';
|
||||
}
|
||||
|
||||
element.dataset.align = 'cursor';
|
||||
return; // Exit early, don't run normal positioning logic
|
||||
}
|
||||
|
||||
// Normalize reference to rect object
|
||||
(reference instanceof Element || reference instanceof Range) &&
|
||||
(reference = extractRect(reference.getBoundingClientRect()));
|
||||
|
||||
// Create anchor rect with swapped opposite edges for positioning
|
||||
const anchorRect = {
|
||||
top: reference.bottom,
|
||||
bottom: reference.top,
|
||||
left: reference.right,
|
||||
right: reference.left,
|
||||
...reference,
|
||||
return function (o, r, f, a, i) {
|
||||
void 0 === f && (f = 'bottom'),
|
||||
void 0 === a && (a = 'center'),
|
||||
void 0 === i && (i = {}),
|
||||
(r instanceof Element || r instanceof Range) &&
|
||||
(r = t(r.getBoundingClientRect()));
|
||||
const n = {
|
||||
top: r.bottom,
|
||||
bottom: r.top,
|
||||
left: r.right,
|
||||
right: r.left,
|
||||
...r,
|
||||
};
|
||||
|
||||
// Viewport bounds (can be overridden via options.bound)
|
||||
const bounds = {
|
||||
const s = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: window.innerHeight,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
|
||||
options.bound &&
|
||||
((options.bound instanceof Element || options.bound instanceof Range) &&
|
||||
(options.bound = extractRect(options.bound.getBoundingClientRect())),
|
||||
Object.assign(bounds, options.bound));
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
// Build axis property maps based on orientation
|
||||
const mainAxis = {}; // Properties for the main positioning axis
|
||||
const crossAxis = {}; // Properties for the perpendicular axis
|
||||
|
||||
for (const prop in AXIS_PROPS) {
|
||||
mainAxis[prop] = AXIS_PROPS[prop][isVertical ? 0 : 1];
|
||||
crossAxis[prop] = AXIS_PROPS[prop][isVertical ? 1 : 0];
|
||||
}
|
||||
|
||||
// Reset element positioning
|
||||
element.style.position = 'absolute';
|
||||
element.style.maxWidth = '';
|
||||
element.style.maxHeight = '';
|
||||
|
||||
// Cross-axis: calculate and apply max size constraint
|
||||
const crossMarginBefore = parseInt(styles[crossAxis.marginBefore]);
|
||||
const crossMarginAfter = parseInt(styles[crossAxis.marginAfter]);
|
||||
const crossMarginTotal = crossMarginBefore + crossMarginAfter;
|
||||
const crossAvailableSpace =
|
||||
bounds[crossAxis.after] - bounds[crossAxis.before] - crossMarginTotal;
|
||||
const crossMaxSize = parseInt(styles[crossAxis.maxSize]);
|
||||
|
||||
(!crossMaxSize || crossAvailableSpace < crossMaxSize) &&
|
||||
(element.style[crossAxis.maxSize] = `${crossAvailableSpace}px`);
|
||||
|
||||
// Main-axis: calculate space on both sides
|
||||
const mainMarginTotal =
|
||||
parseInt(styles[mainAxis.marginBefore]) +
|
||||
parseInt(styles[mainAxis.marginAfter]);
|
||||
const spaceBefore =
|
||||
anchorRect[mainAxis.before] - bounds[mainAxis.before] - mainMarginTotal;
|
||||
const spaceAfter =
|
||||
bounds[mainAxis.after] - anchorRect[mainAxis.after] - mainMarginTotal;
|
||||
|
||||
// Auto-flip to the side with more space if needed
|
||||
((side === mainAxis.before && element[mainAxis.offsetSize] > spaceBefore) ||
|
||||
(side === mainAxis.after && element[mainAxis.offsetSize] > spaceAfter)) &&
|
||||
(side = spaceBefore > spaceAfter ? mainAxis.before : mainAxis.after);
|
||||
|
||||
// Apply main-axis max size constraint
|
||||
const mainAvailableSpace =
|
||||
side === mainAxis.before ? spaceBefore : spaceAfter;
|
||||
const mainMaxSize = parseInt(styles[mainAxis.maxSize]);
|
||||
|
||||
(!mainMaxSize || mainAvailableSpace < mainMaxSize) &&
|
||||
(element.style[mainAxis.maxSize] = `${mainAvailableSpace}px`);
|
||||
|
||||
// Position on main axis
|
||||
const mainScrollOffset = window[mainAxis.scrollOffset];
|
||||
const clampMainPosition = function (pos) {
|
||||
return Math.max(
|
||||
bounds[mainAxis.before],
|
||||
Math.min(
|
||||
pos,
|
||||
bounds[mainAxis.after] - element[mainAxis.offsetSize] - mainMarginTotal,
|
||||
),
|
||||
);
|
||||
i.bound &&
|
||||
((i.bound instanceof Element || i.bound instanceof Range) &&
|
||||
(i.bound = t(i.bound.getBoundingClientRect())),
|
||||
Object.assign(s, i.bound));
|
||||
const l = getComputedStyle(o);
|
||||
const m = {};
|
||||
const b = {};
|
||||
for (const g in e)
|
||||
(m[g] = e[g][f === 'top' || f === 'bottom' ? 0 : 1]),
|
||||
(b[g] = e[g][f === 'top' || f === 'bottom' ? 1 : 0]);
|
||||
(o.style.position = 'absolute'),
|
||||
(o.style.maxWidth = ''),
|
||||
(o.style.maxHeight = '');
|
||||
const d = parseInt(l[b.marginBefore]);
|
||||
const c = parseInt(l[b.marginAfter]);
|
||||
const u = d + c;
|
||||
const p = s[b.after] - s[b.before] - u;
|
||||
const h = parseInt(l[b.maxSize]);
|
||||
(!h || p < h) && (o.style[b.maxSize] = `${p}px`);
|
||||
const x = parseInt(l[m.marginBefore]) + parseInt(l[m.marginAfter]);
|
||||
const y = n[m.before] - s[m.before] - x;
|
||||
const z = s[m.after] - n[m.after] - x;
|
||||
((f === m.before && o[m.offsetSize] > y) ||
|
||||
(f === m.after && o[m.offsetSize] > z)) &&
|
||||
(f = y > z ? m.before : m.after);
|
||||
const S = f === m.before ? y : z;
|
||||
const v = parseInt(l[m.maxSize]);
|
||||
(!v || S < v) && (o.style[m.maxSize] = `${S}px`);
|
||||
const w = window[m.scrollOffset];
|
||||
const O = function (e) {
|
||||
return Math.max(s[m.before], Math.min(e, s[m.after] - o[m.offsetSize] - x));
|
||||
};
|
||||
|
||||
side === mainAxis.before
|
||||
? ((element.style[mainAxis.before] = `${
|
||||
mainScrollOffset +
|
||||
clampMainPosition(
|
||||
anchorRect[mainAxis.before] -
|
||||
element[mainAxis.offsetSize] -
|
||||
mainMarginTotal,
|
||||
)
|
||||
}px`),
|
||||
(element.style[mainAxis.after] = 'auto'))
|
||||
: ((element.style[mainAxis.before] = `${
|
||||
mainScrollOffset + clampMainPosition(anchorRect[mainAxis.after])
|
||||
}px`),
|
||||
(element.style[mainAxis.after] = 'auto'));
|
||||
|
||||
// Position on cross axis based on alignment
|
||||
const crossScrollOffset = window[crossAxis.scrollOffset];
|
||||
const clampCrossPosition = function (pos) {
|
||||
return Math.max(
|
||||
bounds[crossAxis.before],
|
||||
Math.min(
|
||||
pos,
|
||||
bounds[crossAxis.after] - element[crossAxis.offsetSize] - crossMarginTotal,
|
||||
),
|
||||
);
|
||||
f === m.before
|
||||
? ((o.style[m.before] = `${w + O(n[m.before] - o[m.offsetSize] - x)}px`),
|
||||
(o.style[m.after] = 'auto'))
|
||||
: ((o.style[m.before] = `${w + O(n[m.after])}px`),
|
||||
(o.style[m.after] = 'auto'));
|
||||
const B = window[b.scrollOffset];
|
||||
const I = function (e) {
|
||||
return Math.max(s[b.before], Math.min(e, s[b.after] - o[b.offsetSize] - u));
|
||||
};
|
||||
|
||||
switch (align) {
|
||||
switch (a) {
|
||||
case 'start':
|
||||
(element.style[crossAxis.before] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(anchorRect[crossAxis.before] - crossMarginBefore)
|
||||
}px`),
|
||||
(element.style[crossAxis.after] = 'auto');
|
||||
(o.style[b.before] = `${B + I(n[b.before] - d)}px`),
|
||||
(o.style[b.after] = 'auto');
|
||||
break;
|
||||
case 'end':
|
||||
(element.style[crossAxis.before] = 'auto'),
|
||||
(element.style[crossAxis.after] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(
|
||||
document.documentElement[crossAxis.clientSize] -
|
||||
anchorRect[crossAxis.after] -
|
||||
crossMarginAfter,
|
||||
)
|
||||
(o.style[b.before] = 'auto'),
|
||||
(o.style[b.after] = `${
|
||||
B + I(document.documentElement[b.clientSize] - n[b.after] - c)
|
||||
}px`);
|
||||
break;
|
||||
default:
|
||||
// 'center'
|
||||
var crossSize = anchorRect[crossAxis.after] - anchorRect[crossAxis.before];
|
||||
(element.style[crossAxis.before] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(
|
||||
anchorRect[crossAxis.before] +
|
||||
crossSize / 2 -
|
||||
element[crossAxis.offsetSize] / 2 -
|
||||
crossMarginBefore,
|
||||
)
|
||||
var H = n[b.after] - n[b.before];
|
||||
(o.style[b.before] = `${
|
||||
B + I(n[b.before] + H / 2 - o[b.offsetSize] / 2 - d)
|
||||
}px`),
|
||||
(element.style[crossAxis.after] = 'auto');
|
||||
(o.style[b.after] = 'auto');
|
||||
}
|
||||
|
||||
// Store final placement as data attributes
|
||||
(element.dataset.side = side), (element.dataset.align = align);
|
||||
(o.dataset.side = f), (o.dataset.align = a);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -3,71 +3,7 @@ import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
function isSeriesValueValid(seriesValue: number | undefined | null): boolean {
|
||||
return (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to get the focused/highlighted series at a specific position
|
||||
function resolveSeriesColor(series: uPlot.Series, index: number): string {
|
||||
let color = '#000000';
|
||||
if (typeof series.stroke === 'string') {
|
||||
color = series.stroke;
|
||||
} else if (typeof series.fill === 'string') {
|
||||
color = series.fill;
|
||||
} else {
|
||||
const seriesLabel = series.label || `Series ${index}`;
|
||||
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||
color = generateColor(
|
||||
seriesLabel,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function getPreferredSeriesIndex(
|
||||
u: uPlot,
|
||||
timestampIndex: number,
|
||||
e: MouseEvent,
|
||||
): number {
|
||||
const bbox = u.over.getBoundingClientRect();
|
||||
const top = e.clientY - bbox.top;
|
||||
// Prefer series explicitly marked as focused
|
||||
for (let i = 1; i < u.series.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const isSeriesFocused = u.series[i]?._focus === true;
|
||||
const isSeriesShown = u.series[i].show !== false;
|
||||
const seriesValue = u.data[i]?.[timestampIndex];
|
||||
if (isSeriesFocused && isSeriesShown && isSeriesValueValid(seriesValue)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: choose series with Y closest to mouse position
|
||||
let focusedSeriesIndex = -1;
|
||||
let closestPixelDiff = Infinity;
|
||||
for (let i = 1; i < u.series.length; i++) {
|
||||
const series = u.data[i];
|
||||
const seriesValue = series?.[timestampIndex];
|
||||
|
||||
if (isSeriesValueValid(seriesValue) && u.series[i].show !== false) {
|
||||
const yPx = u.valToPos(seriesValue as number, 'y');
|
||||
const diff = Math.abs(yPx - top);
|
||||
if (diff < closestPixelDiff) {
|
||||
closestPixelDiff = diff;
|
||||
focusedSeriesIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return focusedSeriesIndex;
|
||||
}
|
||||
|
||||
export const getFocusedSeriesAtPosition = (
|
||||
e: MouseEvent,
|
||||
u: uPlot,
|
||||
@@ -81,28 +17,74 @@ export const getFocusedSeriesAtPosition = (
|
||||
} | null => {
|
||||
const bbox = u.over.getBoundingClientRect();
|
||||
const left = e.clientX - bbox.left;
|
||||
const top = e.clientY - bbox.top;
|
||||
|
||||
const timestampIndex = u.posToIdx(left);
|
||||
const preferredIndex = getPreferredSeriesIndex(u, timestampIndex, e);
|
||||
let focusedSeriesIndex = -1;
|
||||
let closestPixelDiff = Infinity;
|
||||
|
||||
// Check all series (skip index 0 which is the x-axis)
|
||||
for (let i = 1; i < u.data.length; i++) {
|
||||
const series = u.data[i];
|
||||
const seriesValue = series[timestampIndex];
|
||||
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
const seriesYPx = u.valToPos(seriesValue, 'y');
|
||||
const pixelDiff = Math.abs(seriesYPx - top);
|
||||
|
||||
if (pixelDiff < closestPixelDiff) {
|
||||
closestPixelDiff = pixelDiff;
|
||||
focusedSeriesIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a focused series, return its data
|
||||
if (focusedSeriesIndex > 0) {
|
||||
const series = u.series[focusedSeriesIndex];
|
||||
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
|
||||
|
||||
// Ensure we have a valid value
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
// Get color - try series stroke first, then generate based on label
|
||||
let color = '#000000';
|
||||
if (typeof series.stroke === 'string') {
|
||||
color = series.stroke;
|
||||
} else if (typeof series.fill === 'string') {
|
||||
color = series.fill;
|
||||
} else {
|
||||
// Generate color based on series label (like the tooltip plugin does)
|
||||
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
|
||||
// Detect theme mode by checking body class
|
||||
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||
color = generateColor(
|
||||
seriesLabel,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
}
|
||||
|
||||
if (preferredIndex > 0) {
|
||||
const series = u.series[preferredIndex];
|
||||
const seriesValue = u.data[preferredIndex][timestampIndex];
|
||||
if (isSeriesValueValid(seriesValue)) {
|
||||
const color = resolveSeriesColor(series, preferredIndex);
|
||||
return {
|
||||
seriesIndex: preferredIndex,
|
||||
seriesName: series.label || `Series ${preferredIndex}`,
|
||||
seriesIndex: focusedSeriesIndex,
|
||||
seriesName: series.label || `Series ${focusedSeriesIndex}`,
|
||||
value: seriesValue as number,
|
||||
color,
|
||||
show: series.show !== false,
|
||||
isFocused: true,
|
||||
isFocused: true, // This indicates it's the highlighted/bold one
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface OnClickPluginOpts {
|
||||
onClick: (
|
||||
xValue: number,
|
||||
@@ -155,31 +137,50 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
const yValue = u.posToVal(event.offsetY, 'y');
|
||||
|
||||
// Get the focused/highlighted series (the one that would be bold in hover)
|
||||
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
let metric = {};
|
||||
const { series } = u;
|
||||
const apiResult = opts.apiResponse?.data?.result || [];
|
||||
const outputMetric = {
|
||||
queryName: '',
|
||||
inFocusOrNot: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (
|
||||
focusedSeriesData &&
|
||||
focusedSeriesData.seriesIndex <= apiResult.length
|
||||
) {
|
||||
const { metric: focusedMetric, queryName } =
|
||||
apiResult[focusedSeriesData.seriesIndex - 1] || {};
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
// this is to get the metric value of the focused series
|
||||
if (Array.isArray(series) && series.length > 0) {
|
||||
series.forEach((item, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (item?.show && item?._focus) {
|
||||
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!outputMetric.queryName) {
|
||||
// Get the focused series data
|
||||
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
// If we found a valid focused series, get its data
|
||||
if (
|
||||
focusedSeriesData &&
|
||||
focusedSeriesData.seriesIndex <= apiResult.length
|
||||
) {
|
||||
const { metric: focusedMetric, queryName } =
|
||||
apiResult[focusedSeriesData.seriesIndex - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual data point timestamp from the focused series
|
||||
let actualDataTimestamp = xValue; // fallback to click position timestamp
|
||||
if (focusedSeriesData) {
|
||||
if (focusedSeries) {
|
||||
// Get the data index from the focused series
|
||||
const dataIndex = u.posToIdx(event.offsetX);
|
||||
// Get the actual timestamp from the x-axis data (u.data[0])
|
||||
@@ -208,7 +209,7 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeriesData,
|
||||
focusedSeries,
|
||||
);
|
||||
};
|
||||
u.over.addEventListener('click', handleClick);
|
||||
|
||||
@@ -415,11 +415,7 @@ ToolTipPluginProps): any => {
|
||||
}
|
||||
// Clear and set new content in one operation
|
||||
overlay.replaceChildren(content);
|
||||
placement(overlay, anchor, 'right', 'start', {
|
||||
bound,
|
||||
followCursor: { x: anchor.left, y: anchor.top, offset: 4 },
|
||||
});
|
||||
|
||||
placement(overlay, anchor, 'right', 'start', { bound });
|
||||
showOverlay();
|
||||
} else {
|
||||
hideOverlay();
|
||||
|
||||
@@ -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,12 +102,49 @@ 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),
|
||||
DataSource.LOGS,
|
||||
);
|
||||
(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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -401,14 +401,14 @@ body {
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
margin: 0.5rem;
|
||||
background: var(--bg-ink-300);
|
||||
background: rgba(0, 0, 0);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
color: #fff;
|
||||
z-index: 10000;
|
||||
// pointer-events: none;
|
||||
overflow: auto;
|
||||
max-height: 480px !important;
|
||||
max-width: 300px !important;
|
||||
max-width: 240px !important;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -571,12 +571,6 @@ body {
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
#overlay {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
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())
|
||||
}
|
||||
|
||||
storeID := provider.storeID
|
||||
modelID := provider.modelID
|
||||
if !checkResponse.Allowed {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", tupleReq.User, tupleReq.Relation, tupleReq.Object)
|
||||
}
|
||||
|
||||
return storeID, modelID
|
||||
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
|
||||
}
|
||||
|
||||
3
pkg/cache/cache.go
vendored
3
pkg/cache/cache.go
vendored
@@ -14,7 +14,8 @@ type Cache interface {
|
||||
Set(ctx context.Context, orgID valuer.UUID, cacheKey string, data cachetypes.Cacheable, ttl time.Duration) error
|
||||
|
||||
// Get gets the cacheble entity in the dest entity passed.
|
||||
Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error
|
||||
// TODO: Remove allowExpired from Get.
|
||||
Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable, allowExpired bool) error
|
||||
|
||||
// Delete deletes the cacheable entity from cache
|
||||
Delete(ctx context.Context, orgID valuer.UUID, cacheKey string)
|
||||
|
||||
2
pkg/cache/memorycache/provider.go
vendored
2
pkg/cache/memorycache/provider.go
vendored
@@ -67,7 +67,7 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error {
|
||||
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable, allowExpired bool) error {
|
||||
_, span := provider.settings.Tracer().Start(ctx, "memory.get", trace.WithAttributes(
|
||||
attribute.String(semconv.AttributeDBSystem, "memory"),
|
||||
attribute.String(semconv.AttributeDBStatement, "get "+strings.Join([]string{orgID.StringValue(), cacheKey}, "::")),
|
||||
|
||||
12
pkg/cache/memorycache/provider_test.go
vendored
12
pkg/cache/memorycache/provider_test.go
vendored
@@ -97,7 +97,7 @@ func TestCloneableSetGet(t *testing.T) {
|
||||
assert.IsType(t, &CloneableA{}, insideCache)
|
||||
|
||||
cached := new(CloneableA)
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, "key", cached))
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, "key", cached, false))
|
||||
|
||||
assert.Equal(t, cloneable, cached)
|
||||
// confirm that the cached cloneable is a different pointer
|
||||
@@ -127,7 +127,7 @@ func TestCacheableSetGet(t *testing.T) {
|
||||
assert.Equal(t, "{\"Key\":\"some-random-key\",\"Value\":1,\"Expiry\":1000}", string(insideCache.([]byte)))
|
||||
|
||||
cached := new(CacheableB)
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, "key", cached))
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, "key", cached, false))
|
||||
|
||||
assert.Equal(t, cacheable, cached)
|
||||
assert.NotSame(t, cacheable, cached)
|
||||
@@ -141,7 +141,7 @@ func TestGetWithNilPointer(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var cloneable *CloneableA
|
||||
assert.Error(t, cache.Get(context.Background(), valuer.GenerateUUID(), "key", cloneable))
|
||||
assert.Error(t, cache.Get(context.Background(), valuer.GenerateUUID(), "key", cloneable, false))
|
||||
}
|
||||
|
||||
func TestSetGetWithDifferentTypes(t *testing.T) {
|
||||
@@ -161,7 +161,7 @@ func TestSetGetWithDifferentTypes(t *testing.T) {
|
||||
assert.NoError(t, cache.Set(context.Background(), orgID, "key", cloneable, 10*time.Second))
|
||||
|
||||
cachedCacheable := new(CacheableB)
|
||||
err = cache.Get(context.Background(), orgID, "key", cachedCacheable)
|
||||
err = cache.Get(context.Background(), orgID, "key", cachedCacheable, false)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestCloneableConcurrentSetGet(t *testing.T) {
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
cachedCloneable := new(CloneableA)
|
||||
err := cache.Get(context.Background(), orgID, fmt.Sprintf("key-%d", id), cachedCloneable)
|
||||
err := cache.Get(context.Background(), orgID, fmt.Sprintf("key-%d", id), cachedCloneable, false)
|
||||
// Some keys might not exist due to concurrent access, which is expected
|
||||
_ = err
|
||||
done <- true
|
||||
@@ -210,7 +210,7 @@ func TestCloneableConcurrentSetGet(t *testing.T) {
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
cachedCloneable := new(CloneableA)
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, fmt.Sprintf("key-%d", i), cachedCloneable))
|
||||
assert.NoError(t, cache.Get(context.Background(), orgID, fmt.Sprintf("key-%d", i), cachedCloneable, false))
|
||||
assert.Equal(t, fmt.Sprintf("key-%d", i), cachedCloneable.Key)
|
||||
assert.Equal(t, i, cachedCloneable.Value)
|
||||
// confirm that the cached cacheable is a different pointer
|
||||
|
||||
2
pkg/cache/rediscache/provider.go
vendored
2
pkg/cache/rediscache/provider.go
vendored
@@ -52,7 +52,7 @@ func (c *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey string,
|
||||
return c.client.Set(ctx, strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), data, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error {
|
||||
func (c *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable, allowExpired bool) error {
|
||||
err := c.client.Get(ctx, strings.Join([]string{orgID.StringValue(), cacheKey}, "::")).Scan(dest)
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
|
||||
@@ -42,4 +42,4 @@ type URLShareableOptions struct {
|
||||
SelectColumns []v3.AttributeKey `json:"selectColumns"`
|
||||
}
|
||||
|
||||
var PredefinedAlertLabels = []string{ruletypes.LabelThresholdName, ruletypes.LabelSeverityName, ruletypes.LabelLastSeen}
|
||||
var PredefinedAlertLabels = []string{ruletypes.LabelThresholdName}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -18,16 +17,15 @@ const (
|
||||
|
||||
type AuthZ struct {
|
||||
logger *slog.Logger
|
||||
orgGetter organization.Getter
|
||||
authzService authz.AuthZ
|
||||
}
|
||||
|
||||
func NewAuthZ(logger *slog.Logger, orgGetter organization.Getter, authzService authz.AuthZ) *AuthZ {
|
||||
func NewAuthZ(logger *slog.Logger) *AuthZ {
|
||||
if logger == nil {
|
||||
panic("cannot build authz middleware, logger is empty")
|
||||
}
|
||||
|
||||
return &AuthZ{logger: logger, orgGetter: orgGetter, authzService: authzService}
|
||||
return &AuthZ{logger: logger}
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -109,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackWithClaimsFn) http.HandlerFunc {
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
@@ -123,7 +121,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
return
|
||||
}
|
||||
|
||||
selectors, err := cb(req, claims)
|
||||
selectors, err := cb(req.Context(), claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -138,28 +136,3 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackWithoutClaimsFn) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
orgs, err := middleware.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
selectors, orgID, err := cb(req, orgs)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, translation, typeable, selectors)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
next(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,30 +7,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/role"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"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"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// enables public sharing for dashboard.
|
||||
CreatePublic(context.Context, valuer.UUID, *dashboardtypes.PublicDashboard) error
|
||||
|
||||
// gets the config for public sharing by org_id and dashboard_id.
|
||||
GetPublic(context.Context, valuer.UUID, valuer.UUID) (*dashboardtypes.PublicDashboard, error)
|
||||
|
||||
// get the dashboard data by public dashboard id
|
||||
GetDashboardByPublicID(context.Context, valuer.UUID) (*dashboardtypes.Dashboard, error)
|
||||
|
||||
// gets the org for the given public dashboard
|
||||
GetPublicDashboardOrgAndSelectors(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error)
|
||||
|
||||
// updates the config for public sharing.
|
||||
UpdatePublic(context.Context, *dashboardtypes.PublicDashboard) error
|
||||
|
||||
// disables the public sharing for the dashboard.
|
||||
DeletePublic(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error)
|
||||
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error)
|
||||
@@ -51,18 +32,6 @@ type Module interface {
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
CreatePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicData(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdatePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeletePublic(http.ResponseWriter, *http.Request)
|
||||
|
||||
Create(http.ResponseWriter, *http.Request)
|
||||
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -4,16 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -25,12 +21,10 @@ import (
|
||||
type handler struct {
|
||||
module dashboard.Module
|
||||
providerSettings factory.ProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing) dashboard.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings, querier: querier, licensing: licensing}
|
||||
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings}
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -202,278 +196,3 @@ func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) CreatePublic(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(dashboardtypes.PostablePublicDashboard)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard := dashboardtypes.NewPublicDashboard(req.TimeRangeEnabled, req.DefaultTimeRange, id)
|
||||
err = handler.module.CreatePublic(ctx, valuer.MustNewUUID(claims.OrgID), publicDashboard)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublic(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboardtypes.NewGettablePublicDashboard(publicDashboard))
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicData(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetDashboardByPublicID(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, valuer.MustNewUUID(dashboard.ID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettablePublicDashboardData, err := dashboardtypes.NewPublicDashboardDataFromDashboard(dashboard, publicDashboard)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, gettablePublicDashboardData)
|
||||
}
|
||||
|
||||
func (handler *handler) GetPublicWidgetQueryRange(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
widgetIndex, ok := mux.Vars(r)["index"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "widget index is missing from the path"))
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.GetDashboardByPublicID(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, valuer.MustNewUUID(dashboard.ID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
widgetIdxInt, err := strconv.ParseInt(widgetIndex, 10, 64)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "invalid widget index"))
|
||||
return
|
||||
}
|
||||
|
||||
var startTime, endTime uint64
|
||||
if publicDashboard.TimeRangeEnabled {
|
||||
startTimeUint, err := strconv.ParseUint(r.URL.Query().Get("startTime"), 10, 64)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "invalid startTime"))
|
||||
return
|
||||
}
|
||||
|
||||
endTimeUint, err := strconv.ParseUint(r.URL.Query().Get("endTime"), 10, 64)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "invalid endTime"))
|
||||
return
|
||||
}
|
||||
|
||||
startTime = startTimeUint
|
||||
endTime = endTimeUint
|
||||
} else {
|
||||
timeRange, err := time.ParseDuration(publicDashboard.DefaultTimeRange)
|
||||
if err != nil {
|
||||
// this should't happen as we shouldn't let such values in DB
|
||||
panic(err)
|
||||
}
|
||||
|
||||
startTime = uint64(time.Now().Add(-timeRange).UnixMilli())
|
||||
endTime = uint64(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
query, err := dashboard.GetWidgetQuery(startTime, endTime, widgetIdxInt, handler.providerSettings.Logger)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResults, err := handler.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdatePublic(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(dashboardtypes.UpdatablePublicDashboard)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard, err := handler.module.GetPublic(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
publicDashboard.Update(req.TimeRangeEnabled, req.DefaultTimeRange)
|
||||
err = handler.module.UpdatePublic(ctx, publicDashboard)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeletePublic(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DeletePublic(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,16 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/role"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"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/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -23,18 +19,14 @@ type module struct {
|
||||
store dashboardtypes.Store
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
role role.Module
|
||||
}
|
||||
|
||||
func NewModule(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module) dashboard.Module {
|
||||
func NewModule(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/impldashboard")
|
||||
return &module{
|
||||
store: NewStore(sqlstore),
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
role: role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,87 +50,17 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
storablePublicDashboard, err := module.store.GetPublic(ctx, publicDashboard.DashboardID.StringValue())
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
if storablePublicDashboard != nil {
|
||||
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", storablePublicDashboard.DashboardID)
|
||||
}
|
||||
|
||||
role, err := module.role.GetOrCreate(ctx, roletypes.NewRole(roletypes.AnonymousUserRoleName, roletypes.AnonymousUserRoleDescription, roletypes.RoleTypeManaged.StringValue(), orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.role.Assign(ctx, role.ID, orgID, authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.StringValue(), orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additionObject := authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
|
||||
Type: authtypes.TypeMetaResource,
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
|
||||
)
|
||||
|
||||
err = module.role.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
storableDashboard, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
|
||||
}
|
||||
|
||||
func (module *module) GetPublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) (*dashboardtypes.PublicDashboard, error) {
|
||||
storablePublicDashboard, err := module.store.GetPublic(ctx, dashboardID.StringValue())
|
||||
dashboard, err := dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewPublicDashboardFromStorablePublicDashboard(storablePublicDashboard), nil
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicID(ctx context.Context, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewDashboardFromStorableDashboard(storableDashboard), nil
|
||||
}
|
||||
|
||||
func (module *module) GetPublicDashboardOrgAndSelectors(ctx context.Context, id valuer.UUID, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
|
||||
orgIDs := make([]string, len(orgs))
|
||||
for idx, org := range orgs {
|
||||
orgIDs[idx] = org.ID.StringValue()
|
||||
}
|
||||
|
||||
storableDashboard, err := module.store.GetDashboardByOrgsAndPublicID(ctx, orgIDs, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return []authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
|
||||
}, storableDashboard.OrgID, nil
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
@@ -147,7 +69,12 @@ func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboard
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboardtypes.NewDashboardsFromStorableDashboards(storableDashboards), nil
|
||||
dashboards, err := dashboardtypes.NewDashboardsFromStorableDashboards(storableDashboards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dashboards, nil
|
||||
}
|
||||
|
||||
func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatableDashboard dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error) {
|
||||
@@ -174,10 +101,6 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
return module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
|
||||
}
|
||||
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
|
||||
dashboard, err := module.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
@@ -211,56 +134,7 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.DeletePublic(ctx, orgID, id)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.Delete(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
publicDashboard, err := module.GetPublic(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role, err := module.role.GetOrCreate(ctx, roletypes.NewRole(roletypes.AnonymousUserRoleName, roletypes.AnonymousUserRoleDescription, roletypes.RoleTypeManaged.StringValue(), orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionObject := authtypes.MustNewObject(
|
||||
authtypes.Resource{
|
||||
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
|
||||
Type: authtypes.TypeMetaResource,
|
||||
},
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
|
||||
)
|
||||
|
||||
err = module.role.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
|
||||
@@ -347,17 +221,9 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboards, err := module.store.ListPublic(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := make(map[string]any)
|
||||
maps.Copy(stats, dashboardtypes.NewStatsFromStorableDashboards(dashboards))
|
||||
maps.Copy(stats, dashboardtypes.NewStatsFromStorablePublicDashboards(publicDashboards))
|
||||
return stats, nil
|
||||
return dashboardtypes.NewStatsFromStorableDashboards(dashboards), nil
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{dashboardtypes.TypeableMetaResourceDashboard, dashboardtypes.TypeableMetaResourcesDashboards}
|
||||
return []authtypes.Typeable{dashboardtypes.TypeableResourceDashboard, dashboardtypes.TypeableResourcesDashboards}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
@@ -32,22 +31,9 @@ func (store *store) Create(ctx context.Context, storabledashboard *dashboardtype
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreatePublic(ctx context.Context, storable *dashboardtypes.StorablePublicDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", storable.DashboardID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboard := new(dashboardtypes.StorableDashboard)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
@@ -63,61 +49,9 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
|
||||
storable := new(dashboardtypes.StorablePublicDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("dashboard_id = ?", dashboardID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", dashboardID)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetDashboardByOrgsAndPublicID(ctx context.Context, orgIDs []string, id string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storable := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Join("JOIN public_dashboard").
|
||||
JoinOn("public_dashboard.dashboard_id = dashboard.id").
|
||||
Where("public_dashboard.id = ?", id).
|
||||
Where("org_id IN (?)", bun.In(orgIDs)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodePublicDashboardNotFound, "couldn't find dashboard with id %s ", id)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) GetDashboardByPublicID(ctx context.Context, id string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storable := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Join("JOIN public_dashboard").
|
||||
JoinOn("public_dashboard.dashboard_id = dashboard.id").
|
||||
Where("public_dashboard.id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodePublicDashboardNotFound, "couldn't find dashboard with id %s ", id)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboards := make([]*dashboardtypes.StorableDashboard, 0)
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
@@ -126,30 +60,12 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardty
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "no dashboards found in orgID %s", orgID)
|
||||
}
|
||||
|
||||
return storableDashboards, nil
|
||||
}
|
||||
|
||||
func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.StorablePublicDashboard, error) {
|
||||
storable := make([]*dashboardtypes.StorablePublicDashboard, 0)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&storable).
|
||||
Join("JOIN dashboard").
|
||||
JoinOn("public_dashboard.dashboard_id = dashboard.id").
|
||||
Where("dashboard.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
@@ -160,22 +76,7 @@ func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashb
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with id %s doesn't exist", storableDashboard.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) UpdatePublic(ctx context.Context, storable *dashboardtypes.StorablePublicDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(storable).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", storable.DashboardID)
|
||||
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeAlreadyExists, "dashboard with id %s doesn't exist", storableDashboard.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -196,24 +97,3 @@ func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUI
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) DeletePublic(ctx context.Context, dashboardID string) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(dashboardtypes.StorablePublicDashboard)).
|
||||
Where("dashboard_id = ?", dashboardID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", dashboardID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
return cb(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ type handler struct {
|
||||
module role.Module
|
||||
}
|
||||
|
||||
func NewHandler(module role.Module) role.Handler {
|
||||
return &handler{module: module}
|
||||
func NewHandler(module role.Module) (role.Handler, error) {
|
||||
return &handler{module: module}, nil
|
||||
}
|
||||
|
||||
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -28,6 +28,11 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(roletypes.PostableRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
@@ -35,13 +40,13 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Create(ctx, roletypes.NewRole(req.Name, req.Description, roletypes.RoleTypeCustom.StringValue(), valuer.MustNewUUID(claims.OrgID)))
|
||||
role, err := handler.module.Create(ctx, orgID, req.DisplayName, req.Description)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, nil)
|
||||
render.Success(rw, http.StatusCreated, role.ID.StringValue())
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,6 +56,11 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
@@ -63,7 +73,7 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
role, err := handler.module.Get(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -79,6 +89,11 @@ func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
@@ -102,7 +117,7 @@ func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
objects, err := handler.module.GetObjects(ctx, valuer.MustNewUUID(claims.OrgID), roleID, relation)
|
||||
objects, err := handler.module.GetObjects(ctx, orgID, roleID, relation)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -132,8 +147,13 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roles, err := handler.module.List(ctx, valuer.MustNewUUID(claims.OrgID))
|
||||
roles, err := handler.module.List(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -149,8 +169,18 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
|
||||
return
|
||||
}
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -162,14 +192,7 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
role, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
role.PatchMetadata(req.Name, req.Description)
|
||||
err = handler.module.Patch(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
err = handler.module.Patch(ctx, orgID, roleID, req.DisplayName, req.Description)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -185,14 +208,29 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relation, err := authtypes.NewRelation(mux.Vars(r)["relation"])
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
|
||||
return
|
||||
}
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relationStr, ok := mux.Vars(r)["relation"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
|
||||
return
|
||||
}
|
||||
relation, err := authtypes.NewRelation(relationStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -210,7 +248,7 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), id, relation, patchableObjects.Additions, patchableObjects.Deletions)
|
||||
err = handler.module.PatchObjects(ctx, orgID, roleID, relation, patchableObjects.Additions, patchableObjects.Deletions)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -226,14 +264,24 @@ func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Delete(ctx, valuer.MustNewUUID(claims.OrgID), id)
|
||||
id, ok := mux.Vars(r)["id"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
|
||||
return
|
||||
}
|
||||
roleID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.Delete(ctx, orgID, roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/role"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
@@ -18,31 +17,23 @@ type module struct {
|
||||
authz authz.AuthZ
|
||||
}
|
||||
|
||||
func NewModule(store roletypes.Store, authz authz.AuthZ, registry []role.RegisterTypeable) role.Module {
|
||||
func NewModule(ctx context.Context, store roletypes.Store, authz authz.AuthZ, registry []role.RegisterTypeable) (role.Module, error) {
|
||||
return &module{
|
||||
store: store,
|
||||
authz: authz,
|
||||
registry: registry,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, role *roletypes.Role) error {
|
||||
return module.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
|
||||
}
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, displayName, description string) (*roletypes.Role, error) {
|
||||
role := roletypes.NewRole(displayName, description, orgID)
|
||||
|
||||
func (module *module) GetOrCreate(ctx context.Context, role *roletypes.Role) (*roletypes.Role, error) {
|
||||
existingRole, err := module.store.GetByNameAndOrgID(ctx, role.Name, role.OrgID)
|
||||
storableRole, err := roletypes.NewStorableRoleFromRole(role)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return roletypes.NewRoleFromStorableRole(existingRole), nil
|
||||
}
|
||||
|
||||
err = module.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
|
||||
err = module.store.Create(ctx, storableRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,7 +63,12 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return roletypes.NewRoleFromStorableRole(storableRole), nil
|
||||
role, err := roletypes.NewRoleFromStorableRole(storableRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (module *module) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
|
||||
@@ -88,7 +84,7 @@ func (module *module) GetObjects(ctx context.Context, orgID valuer.UUID, id valu
|
||||
authz.
|
||||
ListObjects(
|
||||
ctx,
|
||||
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.ID.String(), orgID, &authtypes.RelationAssignee),
|
||||
authtypes.MustNewSubject(authtypes.TypeRole, storableRole.ID.String(), authtypes.RelationAssignee),
|
||||
relation,
|
||||
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
|
||||
)
|
||||
@@ -111,14 +107,39 @@ func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes
|
||||
|
||||
roles := make([]*roletypes.Role, len(storableRoles))
|
||||
for idx, storableRole := range storableRoles {
|
||||
roles[idx] = roletypes.NewRoleFromStorableRole(storableRole)
|
||||
role, err := roletypes.NewRoleFromStorableRole(storableRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles[idx] = role
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (module *module) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
|
||||
return module.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
|
||||
func (module *module) Patch(ctx context.Context, orgID valuer.UUID, id valuer.UUID, displayName, description *string) error {
|
||||
storableRole, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role, err := roletypes.NewRoleFromStorableRole(storableRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
role.PatchMetadata(displayName, description)
|
||||
updatedRole, err := roletypes.NewStorableRoleFromRole(role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.Update(ctx, orgID, updatedRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
@@ -140,21 +161,6 @@ func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id va
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Assign(ctx context.Context, id valuer.UUID, orgID valuer.UUID, subject string) error {
|
||||
tuples, err := authtypes.TypeableRole.Tuples(
|
||||
subject,
|
||||
authtypes.RelationAssignee,
|
||||
[]authtypes.Selector{
|
||||
authtypes.MustNewSelector(authtypes.TypeRole, id.StringValue()),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.authz.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) roletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
func NewStore(sqlstore sqlstore.SQLStore) (roletypes.Store, error) {
|
||||
return &store{sqlstore: sqlstore}, nil
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, role *roletypes.StorableRole) error {
|
||||
@@ -38,7 +38,7 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(role).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("orgID = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
@@ -48,23 +48,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByNameAndOrgID(ctx context.Context, name string, orgID valuer.UUID) (*roletypes.StorableRole, error) {
|
||||
role := new(roletypes.StorableRole)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(role).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with name: %s doesn't exist", name)
|
||||
}
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.StorableRole, error) {
|
||||
roles := make([]*roletypes.StorableRole, 0)
|
||||
err := store.
|
||||
@@ -72,7 +55,7 @@ func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.S
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&roles).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("orgID = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "no roles found in org_id: %s", orgID)
|
||||
|
||||
@@ -10,36 +10,30 @@ import (
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Creates the role.
|
||||
Create(context.Context, *roletypes.Role) error
|
||||
// Creates the role metadata
|
||||
Create(context.Context, valuer.UUID, string, string) (*roletypes.Role, error)
|
||||
|
||||
// Gets the role if it exists or creates one.
|
||||
GetOrCreate(context.Context, *roletypes.Role) (*roletypes.Role, error)
|
||||
|
||||
// Gets the role
|
||||
// Gets the role metadata
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*roletypes.Role, error)
|
||||
|
||||
// Gets the objects associated with the given role and relation.
|
||||
// Gets the objects associated with the given role and relation
|
||||
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*authtypes.Object, error)
|
||||
|
||||
// Lists all the roles for the organization.
|
||||
// Lists all the roles metadata for the organization
|
||||
List(context.Context, valuer.UUID) ([]*roletypes.Role, error)
|
||||
|
||||
// Gets all the typeable resources registered from role registry.
|
||||
// Gets all the typeable resources registered from role registry
|
||||
GetResources(context.Context) []*authtypes.Resource
|
||||
|
||||
// Patches the role.
|
||||
Patch(context.Context, valuer.UUID, *roletypes.Role) error
|
||||
// Patches the roles metadata
|
||||
Patch(context.Context, valuer.UUID, valuer.UUID, *string, *string) error
|
||||
|
||||
// Patches the objects in authorization server associated with the given role and relation
|
||||
PatchObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
|
||||
|
||||
// Deletes the role and tuples in authorization server.
|
||||
// Deletes the role metadata and tuples in authorization server
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Assigns role to the given subject.
|
||||
Assign(context.Context, valuer.UUID, valuer.UUID, string) error
|
||||
|
||||
RegisterTypeable
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package implservices
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/servicetypes/servicetypesv1"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
Module services.Module
|
||||
}
|
||||
|
||||
func NewHandler(m services.Module) services.Handler {
|
||||
return &handler{
|
||||
Module: m,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) Get(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var in servicetypesv1.Request
|
||||
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgUUID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Module.Get(req.Context(), orgUUID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *handler) GetTopOperations(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var in servicetypesv1.OperationsRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgUUID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Module.GetTopOperations(req.Context(), orgUUID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *handler) GetEntryPointOperations(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var in servicetypesv1.OperationsRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgUUID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Module.GetEntryPointOperations(req.Context(), orgUUID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package implservices
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/servicetypes/servicetypesv1"
|
||||
)
|
||||
|
||||
// validateTagFilterItems validates the tag filter items. This should be used before using
|
||||
// buildFilterExpression or any other function that uses tag filter items.
|
||||
func validateTagFilterItems(tags []servicetypesv1.TagFilterItem) error {
|
||||
for _, t := range tags {
|
||||
if t.Key == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "key is required")
|
||||
}
|
||||
if strings.ToLower(t.Operator) != "in" && strings.ToLower(t.Operator) != "notin" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "only in and notin operators are supported")
|
||||
}
|
||||
if len(t.StringValues) == 0 && len(t.BoolValues) == 0 && len(t.NumberValues) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one of stringValues, boolValues, or numberValues must be populated")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFilterExpression converts tag filters into a QBv5-compatible filter expression and set of variableItems.
|
||||
// before calling this function, should validate tags with validateTagFilterItems first.
|
||||
func buildFilterExpression(tags []servicetypesv1.TagFilterItem) (string, map[string]qbtypes.VariableItem) {
|
||||
variables := make(map[string]qbtypes.VariableItem)
|
||||
parts := make([]string, 0, len(tags))
|
||||
valueItr := 1
|
||||
for _, t := range tags {
|
||||
valueIdentifier := fmt.Sprintf("%d", valueItr)
|
||||
|
||||
switch strings.ToLower(t.Operator) {
|
||||
case "notin":
|
||||
if vals, ok := pickInValuesFromTag(t); ok {
|
||||
variables[valueIdentifier] = qbtypes.VariableItem{Type: qbtypes.DynamicVariableType, Value: vals}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s NOT IN $%s", t.Key, valueIdentifier))
|
||||
case "in":
|
||||
if vals, ok := pickInValuesFromTag(t); ok {
|
||||
variables[valueIdentifier] = qbtypes.VariableItem{Type: qbtypes.DynamicVariableType, Value: vals}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s IN $%s", t.Key, valueIdentifier))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
valueItr++
|
||||
}
|
||||
|
||||
filterExpr := strings.Join(parts, " AND ")
|
||||
return filterExpr, variables
|
||||
}
|
||||
|
||||
// pickInValuesFromTag returns a []any for IN operator in the precedence order of
|
||||
// StringValues, BoolValues, NumberValues. Returns false if none are populated.
|
||||
func pickInValuesFromTag(t servicetypesv1.TagFilterItem) ([]any, bool) {
|
||||
if len(t.StringValues) > 0 {
|
||||
vals := make([]any, 0, len(t.StringValues))
|
||||
for _, v := range t.StringValues {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return vals, true
|
||||
}
|
||||
if len(t.BoolValues) > 0 {
|
||||
vals := make([]any, 0, len(t.BoolValues))
|
||||
for _, v := range t.BoolValues {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return vals, true
|
||||
}
|
||||
if len(t.NumberValues) > 0 {
|
||||
vals := make([]any, 0, len(t.NumberValues))
|
||||
for _, v := range t.NumberValues {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return vals, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// toFloat safely converts a cell value to float64, returning 0 on type mismatch.
|
||||
func toFloat(row []any, idx int) float64 {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := row[idx].(float64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// toUint64 safely converts a cell value to uint64.
|
||||
func toUint64(row []any, idx int) uint64 {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := row[idx].(uint64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// applyOpsToItems sets topLevelOps for matching service names.
|
||||
// If opsMap is nil, it performs no changes.
|
||||
func applyOpsToItems(items []*servicetypesv1.ResponseItem, opsMap map[string][]string) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
if opsMap == nil {
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if items[i] == nil {
|
||||
continue
|
||||
}
|
||||
if tops, ok := opsMap[items[i].ServiceName]; ok {
|
||||
items[i].DataWarning.TopLevelOps = tops
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user