Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e0ef2515 | ||
|
|
fecf6667a3 | ||
|
|
bda2316377 | ||
|
|
fff7f8fc76 | ||
|
|
8cfeef4521 | ||
|
|
d85a1a21ac | ||
|
|
17f48d656d | ||
|
|
2d6774da68 | ||
|
|
6404e7388e | ||
|
|
62a9d7e602 | ||
|
|
3a2c7a7a68 | ||
|
|
33e70d1f37 | ||
|
|
85f04e4bae | ||
|
|
53f9e7d811 | ||
|
|
ad46e22561 | ||
|
|
e79195ccf1 | ||
|
|
f77bb888a8 | ||
|
|
baa15baea9 | ||
|
|
316e6821f1 | ||
|
|
a1fa2769e4 | ||
|
|
decb660992 | ||
|
|
0acbcf8322 | ||
|
|
11eabdc2ac | ||
|
|
eb94554f5a | ||
|
|
e8280dbea4 | ||
|
|
44ea237039 | ||
|
|
72b0214d1d | ||
|
|
386a215324 | ||
|
|
ba0ba4bbc9 | ||
|
|
d60c9ab36b | ||
|
|
90770b90bd | ||
|
|
a19874c1dd | ||
|
|
65ff460d63 | ||
|
|
b9d542a294 | ||
|
|
e75e5bdbdb | ||
|
|
0d03203977 |
3
.github/workflows/build-community.yaml
vendored
3
.github/workflows/build-community.yaml
vendored
@@ -74,7 +74,8 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=community
|
||||
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.time=${{ needs.prepare.outputs.time }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}'
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./pkg/query-service/Dockerfile.multi-arch
|
||||
|
||||
3
.github/workflows/build-enterprise.yaml
vendored
3
.github/workflows/build-enterprise.yaml
vendored
@@ -108,7 +108,8 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1'
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./ee/query-service/Dockerfile.multi-arch
|
||||
|
||||
3
.github/workflows/build-staging.yaml
vendored
3
.github/workflows/build-staging.yaml
vendored
@@ -107,7 +107,8 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1'
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./ee/query-service/Dockerfile.multi-arch
|
||||
|
||||
@@ -165,12 +165,6 @@ alertmanager:
|
||||
# Retention of the notification logs.
|
||||
retention: 120h
|
||||
|
||||
|
||||
##################### Analytics #####################
|
||||
analytics:
|
||||
# Whether to enable analytics.
|
||||
enabled: false
|
||||
|
||||
##################### Emailing #####################
|
||||
emailing:
|
||||
# Whether to enable emailing.
|
||||
@@ -215,3 +209,18 @@ sharder:
|
||||
single:
|
||||
# The org id to which this instance belongs to.
|
||||
org_id: org_id
|
||||
|
||||
##################### Analytics #####################
|
||||
analytics:
|
||||
# Whether to enable analytics.
|
||||
enabled: false
|
||||
segment:
|
||||
# The key to use for segment.
|
||||
key: ""
|
||||
|
||||
##################### StatsReporter #####################
|
||||
statsreporter:
|
||||
# Whether to enable stats reporter. This is used to provide valuable insights to the SigNoz team. It does not collect any sensitive/PII data.
|
||||
enabled: true
|
||||
# The interval at which the stats are collected.
|
||||
interval: 6h
|
||||
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.86.1
|
||||
image: signoz/signoz:v0.87.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.86.1
|
||||
image: signoz/signoz:v0.87.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -177,7 +177,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.86.1}
|
||||
image: signoz/signoz:${VERSION:-v0.87.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.86.1}
|
||||
image: signoz/signoz:${VERSION:-v0.87.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
51
docs/contributing/go/endpoint.md
Normal file
51
docs/contributing/go/endpoint.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Endpoint
|
||||
|
||||
This guide outlines the recommended approach for designing endpoints, with a focus on entity relationships, RESTful structure, and examples from the codebase.
|
||||
|
||||
## How do we design an endpoint?
|
||||
|
||||
### Understand the core entities and their relationships
|
||||
|
||||
Start with understanding the core entities and their relationships. For example:
|
||||
|
||||
- **Organization**: an organization can have multiple users
|
||||
|
||||
### Structure Endpoints RESTfully
|
||||
|
||||
Endpoints should reflect the resource hierarchy and follow RESTful conventions. Use clear, **pluralized resource names** and versioning. For example:
|
||||
|
||||
- `POST /v1/organizations` — Create an organization
|
||||
- `GET /v1/organizations/:id` — Get an organization by id
|
||||
- `DELETE /v1/organizations/:id` — Delete an organization by id
|
||||
- `PUT /v1/organizations/:id` — Update an organization by id
|
||||
- `GET /v1/organizations/:id/users` — Get all users in an organization
|
||||
- `GET /v1/organizations/me/users` — Get all users in my organization
|
||||
|
||||
Think in terms of resource navigation in a file system. For example, to find your organization, you would navigate to the root of the file system and then to the `organizations` directory. To find a user in an organization, you would navigate to the `organizations` directory and then to the `id` directory.
|
||||
|
||||
```bash
|
||||
v1/
|
||||
├── organizations/
|
||||
│ └── 123/
|
||||
│ └── users/
|
||||
```
|
||||
|
||||
`me` endpoints are special. They are used to determine the actual id via some auth/external mechanism. For `me` endpoints, think of the `me` directory being symlinked to your organization directory. For example, if you are a part of the organization `123`, the `me` directory will be symlinked to `/v1/organizations/123`:
|
||||
|
||||
```bash
|
||||
v1/
|
||||
├── organizations/
|
||||
│ └── me/ -> symlink to /v1/organizations/123
|
||||
│ └── users/
|
||||
│ └── 123/
|
||||
│ └── users/
|
||||
```
|
||||
|
||||
> 💡 **Note**: There are various ways to structure endpoints. Some prefer to use singular resource names instead of `me`. Others prefer to use singular resource names for all endpoints. We have, however, chosen to standardize our endpoints in the manner described above.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Use clear, **plural resource names**
|
||||
- Use `me` endpoints for determining the actual id via some auth mechanism
|
||||
|
||||
> 💡 **Note**: When in doubt, diagram the relationships and walk through the user flows as if navigating a file system. This will help you design endpoints that are both logical and user-friendly.
|
||||
106
docs/contributing/go/provider.md
Normal file
106
docs/contributing/go/provider.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Provider
|
||||
|
||||
SigNoz is built on the provider pattern, a design approach where code is organized into providers that handle specific application responsibilities. Providers act as adapter components that integrate with external services and deliver required functionality to the application.
|
||||
|
||||
> 💡 **Note**: Coming from a DDD background? Providers are similar (not exactly the same) to adapter/infrastructure services.
|
||||
|
||||
## How to create a new provider?
|
||||
|
||||
To create a new provider, create a directory in the `pkg/` directory named after your provider. The provider package consists of four key components:
|
||||
|
||||
- **Interface** (`pkg/<name>/<name>.go`): Defines the provider's interface. Other packages should import this interface to use the provider.
|
||||
- **Config** (`pkg/<name>/config.go`): Contains provider configuration, implementing the `factory.Config` interface from [factory/config.go](/pkg/factory/config.go).
|
||||
- **Implementation** (`pkg/<name>/<implname><name>/provider.go`): Contains the provider implementation, including a `NewProvider` function that returns a `factory.Provider` interface from [factory/provider.go](/pkg/factory/provider.go).
|
||||
- **Mock** (`pkg/<name>/<name>test.go`): Provides mocks for the provider, typically used by dependent packages for unit testing.
|
||||
|
||||
For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus engine to the application:
|
||||
|
||||
- `pkg/prometheus/prometheus.go` - Interface definition
|
||||
- `pkg/prometheus/config.go` - Configuration
|
||||
- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation
|
||||
- `pkg/prometheus/prometheustest/provider.go` - Mock implementation
|
||||
|
||||
## How to wire it up?
|
||||
|
||||
The `pkg/signoz` package contains the inversion of control container responsible for wiring providers. It handles instantiation, configuration, and assembly of providers based on configuration metadata.
|
||||
|
||||
> 💡 **Note**: Coming from a Java background? Providers are similar to Spring beans.
|
||||
|
||||
Wiring up a provider involves three steps:
|
||||
|
||||
1. Wiring up the configuration
|
||||
Add your config from `pkg/<name>/config.go` to the `pkg/signoz/config.Config` struct and in new factories:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
...
|
||||
MyProvider myprovider.Config `mapstructure:"myprovider"`
|
||||
...
|
||||
}
|
||||
|
||||
func NewConfig(ctx context.Context, resolverConfig config.ResolverConfig, ....) (Config, error) {
|
||||
...
|
||||
configFactories := []factory.ConfigFactory{
|
||||
myprovider.NewConfigFactory(),
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
2. Wiring up the provider
|
||||
Add available provider implementations in `pkg/signoz/provider.go`:
|
||||
|
||||
```go
|
||||
func NewMyProviderFactories() factory.NamedMap[factory.ProviderFactory[myprovider.MyProvider, myprovider.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
myproviderone.NewFactory(),
|
||||
myprovidertwo.NewFactory(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
3. Instantiate the provider by adding it to the `SigNoz` struct in `pkg/signoz/signoz.go`:
|
||||
|
||||
```go
|
||||
type SigNoz struct {
|
||||
...
|
||||
MyProvider myprovider.MyProvider
|
||||
...
|
||||
}
|
||||
|
||||
func New(...) (*SigNoz, error) {
|
||||
...
|
||||
myprovider, err := myproviderone.New(ctx, settings, config.MyProvider, "one/two")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## How to use it?
|
||||
|
||||
To use a provider, import its interface. For example, to use the prometheus provider, import `pkg/prometheus/prometheus.go`:
|
||||
|
||||
```go
|
||||
import "github.com/SigNoz/signoz/pkg/prometheus/prometheus"
|
||||
|
||||
func CreateSomething(ctx context.Context, prometheus prometheus.Prometheus) {
|
||||
...
|
||||
prometheus.DoSomething()
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Why do we need this?
|
||||
|
||||
Like any dependency injection framework, providers decouple the codebase from implementation details. This is especially valuable in SigNoz's large codebase, where we need to swap implementations without changing dependent code. The provider pattern offers several benefits apart from the obvious one of decoupling:
|
||||
|
||||
- Configuration is **defined with each provider and centralized in one place**, making it easier to understand and manage through various methods (environment variables, config files, etc.)
|
||||
- Provider mocking is **straightforward for unit testing**, with a consistent pattern for locating mocks
|
||||
- **Multiple implementations** of the same provider are **supported**, as demonstrated by our sqlstore provider
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Use the provider pattern wherever applicable.
|
||||
- Always create a provider **irrespective of the number of implementations**. This makes it easier to add new implementations in the future.
|
||||
@@ -211,3 +211,16 @@ func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID va
|
||||
|
||||
return license.Features, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
activeLicense, err := provider.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return licensetypes.NewStatsFromLicense(activeLicense), nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ builds:
|
||||
- -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
|
||||
- >-
|
||||
{{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
querierAPI "github.com/SigNoz/signoz/pkg/querier"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
@@ -58,8 +59,9 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
FieldsAPI: fields.NewAPI(signoz.TelemetryStore, signoz.Instrumentation.Logger()),
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Querier),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -294,6 +294,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV5Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
"uuid": "^8.3.2",
|
||||
"web-vitals": "^0.2.4",
|
||||
"webpack": "5.94.0",
|
||||
"webpack-dev-server": "^4.15.2",
|
||||
"webpack-dev-server": "^5.2.1",
|
||||
"webpack-retry-chunk-load-plugin": "3.1.1",
|
||||
"xstate": "^4.31.0"
|
||||
},
|
||||
@@ -197,7 +197,6 @@
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
"compression-webpack-plugin": "9.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"critters-webpack-plugin": "^3.0.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^16.1.4",
|
||||
@@ -235,7 +234,7 @@
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript-plugin-css-modules": "5.0.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.(js|jsx|ts|tsx)": [
|
||||
|
||||
@@ -95,7 +95,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
usersData.data
|
||||
) {
|
||||
const isOnboardingComplete = orgPreferences?.find(
|
||||
(preference: Record<string, any>) => preference.key === 'ORG_ONBOARDING',
|
||||
(preference: Record<string, any>) => preference.name === 'org_onboarding',
|
||||
)?.value;
|
||||
|
||||
const isFirstUser = checkFirstTimeUser();
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetAllOrgPreferencesResponseProps } from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const getAllOrgPreferences = async (): Promise<
|
||||
SuccessResponse<GetAllOrgPreferencesResponseProps> | ErrorResponse
|
||||
> => {
|
||||
const response = await axios.get(`/org/preferences`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getAllOrgPreferences;
|
||||
@@ -1,18 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetAllUserPreferencesResponseProps } from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const getAllUserPreferences = async (): Promise<
|
||||
SuccessResponse<GetAllUserPreferencesResponseProps> | ErrorResponse
|
||||
> => {
|
||||
const response = await axios.get(`/user/preferences`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getAllUserPreferences;
|
||||
@@ -1,20 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetOrgPreferenceResponseProps } from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const getOrgPreference = async ({
|
||||
preferenceID,
|
||||
}: {
|
||||
preferenceID: string;
|
||||
}): Promise<SuccessResponse<GetOrgPreferenceResponseProps> | ErrorResponse> => {
|
||||
const response = await axios.get(`/org/preferences/${preferenceID}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getOrgPreference;
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetUserPreferenceResponseProps } from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const getUserPreference = async ({
|
||||
preferenceID,
|
||||
}: {
|
||||
preferenceID: string;
|
||||
}): Promise<
|
||||
SuccessResponse<GetUserPreferenceResponseProps> | ErrorResponse
|
||||
> => {
|
||||
const response = await axios.get(`/user/preferences/${preferenceID}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getUserPreference;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
UpdateOrgPreferenceProps,
|
||||
UpdateOrgPreferenceResponseProps,
|
||||
} from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const updateOrgPreference = async (
|
||||
preferencePayload: UpdateOrgPreferenceProps,
|
||||
): Promise<
|
||||
SuccessResponse<UpdateOrgPreferenceResponseProps> | ErrorResponse
|
||||
> => {
|
||||
const response = await axios.put(
|
||||
`/org/preferences/${preferencePayload.preferenceID}`,
|
||||
{
|
||||
preference_value: preferencePayload.value,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default updateOrgPreference;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
UpdateUserPreferenceProps,
|
||||
UpdateUserPreferenceResponseProps,
|
||||
} from 'types/api/preferences/userOrgPreferences';
|
||||
|
||||
const updateUserPreference = async (
|
||||
preferencePayload: UpdateUserPreferenceProps,
|
||||
): Promise<
|
||||
SuccessResponse<UpdateUserPreferenceResponseProps> | ErrorResponse
|
||||
> => {
|
||||
const response = await axios.put(
|
||||
`/user/preferences/${preferencePayload.preferenceID}`,
|
||||
{
|
||||
preference_value: preferencePayload.value,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default updateUserPreference;
|
||||
@@ -196,8 +196,6 @@ export interface FunnelOverviewResponse {
|
||||
avg_rate: number;
|
||||
conversion_rate: number | null;
|
||||
errors: number;
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
p99_latency: number;
|
||||
latency: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
23
frontend/src/api/v1/org/preferences/list.ts
Normal file
23
frontend/src/api/v1/org/preferences/list.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/preferences/list';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
|
||||
const listPreference = async (): Promise<
|
||||
SuccessResponseV2<OrgPreference[]>
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/org/preferences`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default listPreference;
|
||||
25
frontend/src/api/v1/org/preferences/name/get.ts
Normal file
25
frontend/src/api/v1/org/preferences/name/get.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/preferences/get';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
|
||||
const getPreference = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<OrgPreference>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(
|
||||
`/org/preferences/${props.name}`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPreference;
|
||||
22
frontend/src/api/v1/org/preferences/name/update.ts
Normal file
22
frontend/src/api/v1/org/preferences/name/update.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/preferences/update';
|
||||
|
||||
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/org/preferences/${props.name}`, {
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default update;
|
||||
@@ -1,24 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/user/getUserPreference';
|
||||
|
||||
const getPreference = async (): Promise<
|
||||
SuccessResponse<PayloadProps> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.get(`/user/preferences`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPreference;
|
||||
21
frontend/src/api/v1/user/preferences/list.ts
Normal file
21
frontend/src/api/v1/user/preferences/list.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/preferences/list';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
const list = async (): Promise<SuccessResponseV2<UserPreference[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/user/preferences`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default list;
|
||||
25
frontend/src/api/v1/user/preferences/name/get.ts
Normal file
25
frontend/src/api/v1/user/preferences/name/get.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/preferences/get';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
const get = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserPreference>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(
|
||||
`/user/preferences/${props.name}`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
22
frontend/src/api/v1/user/preferences/name/update.ts
Normal file
22
frontend/src/api/v1/user/preferences/name/update.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/preferences/update';
|
||||
|
||||
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/user/preferences/${props.name}`, {
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default update;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -52,11 +53,32 @@ jest.mock('hooks/saveViews/useDeleteView', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock usePreferenceSync
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ExplorerCard', () => {
|
||||
it('renders a card with a title and a description', () => {
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||
<PreferenceContextProvider>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||
</PreferenceContextProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
expect(screen.queryByText('Query Builder')).not.toBeInTheDocument();
|
||||
@@ -65,7 +87,9 @@ describe('ExplorerCard', () => {
|
||||
it('renders a save view button', () => {
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||
<PreferenceContextProvider>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
|
||||
</PreferenceContextProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
expect(screen.queryByText('Save view')).not.toBeInTheDocument();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
DeleteViewHandlerProps,
|
||||
@@ -106,7 +107,11 @@ export const isQueryUpdatedInView = ({
|
||||
!isEqual(
|
||||
options?.selectColumns,
|
||||
extraData && JSON.parse(extraData)?.selectColumns,
|
||||
)
|
||||
) ||
|
||||
(stagedQuery?.builder?.queryData?.[0]?.dataSource === DataSource.LOGS &&
|
||||
(!isEqual(options?.format, extraData && JSON.parse(extraData)?.format) ||
|
||||
!isEqual(options?.maxLines, extraData && JSON.parse(extraData)?.maxLines) ||
|
||||
!isEqual(options?.fontSize, extraData && JSON.parse(extraData)?.fontSize)))
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ const formatMap = {
|
||||
'MM/dd HH:mm': DATE_TIME_FORMATS.SLASH_SHORT,
|
||||
'MM/DD': DATE_TIME_FORMATS.DATE_SHORT,
|
||||
'YY-MM': DATE_TIME_FORMATS.YEAR_MONTH,
|
||||
'MMM d, yyyy, h:mm:ss aaaa': DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
YY: DATE_TIME_FORMATS.YEAR_SHORT,
|
||||
};
|
||||
|
||||
|
||||
@@ -425,3 +425,79 @@ export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [];
|
||||
|
||||
export const metricsUnknownTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.MAX,
|
||||
label: 'Max',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MIN,
|
||||
label: 'Min',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.SUM,
|
||||
label: 'Sum',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.AVG,
|
||||
label: 'Avg',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE,
|
||||
label: 'Rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.INCREASE,
|
||||
label: 'Increase',
|
||||
},
|
||||
];
|
||||
|
||||
export const metricsUnknownSpaceAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.SUM,
|
||||
label: 'Sum',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.AVG,
|
||||
label: 'Avg',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MIN,
|
||||
label: 'Min',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MAX,
|
||||
label: 'Max',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P50,
|
||||
label: 'P50',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P75,
|
||||
label: 'P75',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P90,
|
||||
label: 'P90',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P95,
|
||||
label: 'P95',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P99,
|
||||
label: 'P99',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
@@ -17,7 +17,6 @@ import Delete from './Delete';
|
||||
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
||||
const { t } = useTranslation(['channels']);
|
||||
const { notifications } = useNotifications();
|
||||
const [channels, setChannels] = useState<Channels[]>(allChannels);
|
||||
const { user } = useAppContext();
|
||||
const [action] = useComponentPermission(['new_alert_action'], user.role);
|
||||
|
||||
@@ -56,14 +55,19 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
|
||||
<Button onClick={(): void => onClickEditHandler(id)} type="link">
|
||||
{t('column_channel_edit')}
|
||||
</Button>
|
||||
<Delete id={id} setChannels={setChannels} notifications={notifications} />
|
||||
<Delete id={id} notifications={notifications} />
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizeTable columns={columns} dataSource={channels} rowKey="id" bordered />
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
dataSource={allChannels}
|
||||
rowKey="id"
|
||||
bordered
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Button } from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import deleteChannel from 'api/channels/delete';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function Delete({ notifications, setChannels, id }: DeleteProps): JSX.Element {
|
||||
function Delete({ notifications, id }: DeleteProps): JSX.Element {
|
||||
const { t } = useTranslation(['channels']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onClickHandler = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -21,7 +22,8 @@ function Delete({ notifications, setChannels, id }: DeleteProps): JSX.Element {
|
||||
message: 'Success',
|
||||
description: t('channel_delete_success'),
|
||||
});
|
||||
setChannels((preChannels) => preChannels.filter((e) => e.id !== id));
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['getChannels']);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
@@ -46,7 +48,6 @@ function Delete({ notifications, setChannels, id }: DeleteProps): JSX.Element {
|
||||
|
||||
interface DeleteProps {
|
||||
notifications: NotificationInstance;
|
||||
setChannels: Dispatch<SetStateAction<Channels[]>>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
|
||||
import {
|
||||
MetricsExplorerEventKeys,
|
||||
MetricsExplorerEvents,
|
||||
} from 'container/MetricsExplorer/events';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { FormattingOptions } from 'providers/preferences/types';
|
||||
import {
|
||||
CSSProperties,
|
||||
Dispatch,
|
||||
@@ -140,7 +145,9 @@ function ExplorerOptions({
|
||||
panelType,
|
||||
});
|
||||
} else if (isMetricsExplorer) {
|
||||
logEvent('Metrics Explorer: Save view clicked', {
|
||||
logEvent(MetricsExplorerEvents.SaveViewClicked, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
@@ -184,8 +191,10 @@ function ExplorerOptions({
|
||||
panelType,
|
||||
});
|
||||
} else if (isMetricsExplorer) {
|
||||
logEvent('Metrics Explorer: Create alert', {
|
||||
logEvent(MetricsExplorerEvents.AddToAlertClicked, {
|
||||
panelType,
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,11 +227,14 @@ function ExplorerOptions({
|
||||
panelType,
|
||||
});
|
||||
} else if (isMetricsExplorer) {
|
||||
logEvent('Metrics Explorer: Add to dashboard clicked', {
|
||||
logEvent(MetricsExplorerEvents.AddToDashboardClicked, {
|
||||
panelType,
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
});
|
||||
}
|
||||
setIsExport(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLogsExplorer, isMetricsExplorer, panelType, setIsExport, sourcepage]);
|
||||
|
||||
const {
|
||||
@@ -259,17 +271,26 @@ function ExplorerOptions({
|
||||
const getUpdatedExtraData = (
|
||||
extraData: string | undefined,
|
||||
newSelectedColumns: BaseAutocompleteData[],
|
||||
formattingOptions?: FormattingOptions,
|
||||
): string => {
|
||||
let updatedExtraData;
|
||||
|
||||
if (extraData) {
|
||||
const parsedExtraData = JSON.parse(extraData);
|
||||
parsedExtraData.selectColumns = newSelectedColumns;
|
||||
if (formattingOptions) {
|
||||
parsedExtraData.format = formattingOptions.format;
|
||||
parsedExtraData.maxLines = formattingOptions.maxLines;
|
||||
parsedExtraData.fontSize = formattingOptions.fontSize;
|
||||
}
|
||||
updatedExtraData = JSON.stringify(parsedExtraData);
|
||||
} else {
|
||||
updatedExtraData = JSON.stringify({
|
||||
color: Color.BG_SIENNA_500,
|
||||
selectColumns: newSelectedColumns,
|
||||
format: formattingOptions?.format,
|
||||
maxLines: formattingOptions?.maxLines,
|
||||
fontSize: formattingOptions?.fontSize,
|
||||
});
|
||||
}
|
||||
return updatedExtraData;
|
||||
@@ -278,6 +299,14 @@ function ExplorerOptions({
|
||||
const updatedExtraData = getUpdatedExtraData(
|
||||
extraData,
|
||||
options?.selectColumns,
|
||||
// pass this only for logs
|
||||
sourcepage === DataSource.LOGS
|
||||
? {
|
||||
format: options?.format,
|
||||
maxLines: options?.maxLines,
|
||||
fontSize: options?.fontSize,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -506,6 +535,14 @@ function ExplorerOptions({
|
||||
color,
|
||||
selectColumns: options.selectColumns,
|
||||
version: 1,
|
||||
...// pass this only for logs
|
||||
(sourcepage === DataSource.LOGS
|
||||
? {
|
||||
format: options?.format,
|
||||
maxLines: options?.maxLines,
|
||||
fontSize: options?.fontSize,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
notifications,
|
||||
panelType: panelType || PANEL_TYPES.LIST,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Alert, Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
|
||||
import { K8sPodsListPayload } from 'api/infraMonitoring/getK8sPodsList';
|
||||
import getAllUserPreferences from 'api/preferences/getAllUserPreference';
|
||||
import updateUserPreferenceAPI from 'api/preferences/updateUserPreference';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -29,8 +29,8 @@ import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { UserPreference } from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
@@ -185,7 +185,7 @@ export default function Home(): JSX.Element {
|
||||
|
||||
const processUserPreferences = (userPreferences: UserPreference[]): void => {
|
||||
const checklistSkipped = userPreferences?.find(
|
||||
(preference) => preference.key === 'WELCOME_CHECKLIST_DO_LATER',
|
||||
(preference) => preference.name === 'welcome_checklist_do_later',
|
||||
)?.value;
|
||||
|
||||
const updatedChecklistItems = cloneDeep(checklistItems);
|
||||
@@ -194,7 +194,7 @@ export default function Home(): JSX.Element {
|
||||
const newItem = { ...item };
|
||||
newItem.isSkipped =
|
||||
userPreferences?.find(
|
||||
(preference) => preference.key === item.skippedPreferenceKey,
|
||||
(preference) => preference.name === item.skippedPreferenceKey,
|
||||
)?.value || false;
|
||||
return newItem;
|
||||
});
|
||||
@@ -206,13 +206,13 @@ export default function Home(): JSX.Element {
|
||||
|
||||
// Fetch User Preferences
|
||||
const { refetch: refetchUserPreferences } = useQuery({
|
||||
queryFn: () => getAllUserPreferences(),
|
||||
queryFn: () => listUserPreferences(),
|
||||
queryKey: ['getUserPreferences'],
|
||||
enabled: true,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (response) => {
|
||||
if (response.payload && response.payload.data) {
|
||||
processUserPreferences(response.payload.data);
|
||||
if (response.data) {
|
||||
processUserPreferences(response.data);
|
||||
}
|
||||
|
||||
setLoadingUserPreferences(false);
|
||||
@@ -239,7 +239,7 @@ export default function Home(): JSX.Element {
|
||||
setUpdatingUserPreferences(true);
|
||||
|
||||
updateUserPreference({
|
||||
preferenceID: 'WELCOME_CHECKLIST_DO_LATER',
|
||||
name: 'welcome_checklist_do_later',
|
||||
value: true,
|
||||
});
|
||||
};
|
||||
@@ -249,7 +249,7 @@ export default function Home(): JSX.Element {
|
||||
setUpdatingUserPreferences(true);
|
||||
|
||||
updateUserPreference({
|
||||
preferenceID: item.skippedPreferenceKey,
|
||||
name: item.skippedPreferenceKey,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import ROUTES from 'constants/routes';
|
||||
import { ChecklistItem } from './HomeChecklist/HomeChecklist';
|
||||
|
||||
export const checkListStepToPreferenceKeyMap = {
|
||||
WILL_DO_LATER: 'WELCOME_CHECKLIST_DO_LATER',
|
||||
SEND_LOGS: 'WELCOME_CHECKLIST_SEND_LOGS_SKIPPED',
|
||||
SEND_TRACES: 'WELCOME_CHECKLIST_SEND_TRACES_SKIPPED',
|
||||
SEND_INFRA_METRICS: 'WELCOME_CHECKLIST_SEND_INFRA_METRICS_SKIPPED',
|
||||
SETUP_DASHBOARDS: 'WELCOME_CHECKLIST_SETUP_DASHBOARDS_SKIPPED',
|
||||
SETUP_ALERTS: 'WELCOME_CHECKLIST_SETUP_ALERTS_SKIPPED',
|
||||
SETUP_SAVED_VIEWS: 'WELCOME_CHECKLIST_SETUP_SAVED_VIEW_SKIPPED',
|
||||
SETUP_WORKSPACE: 'WELCOME_CHECKLIST_SETUP_WORKSPACE_SKIPPED',
|
||||
ADD_DATA_SOURCE: 'WELCOME_CHECKLIST_ADD_DATA_SOURCE_SKIPPED',
|
||||
WILL_DO_LATER: 'welcome_checklist_do_later',
|
||||
SEND_LOGS: 'welcome_checklist_send_logs_skipped',
|
||||
SEND_TRACES: 'welcome_checklist_send_traces_skipped',
|
||||
SEND_INFRA_METRICS: 'welcome_checklist_send_infra_metrics_skipped',
|
||||
SETUP_DASHBOARDS: 'welcome_checklist_setup_dashboards_skipped',
|
||||
SETUP_ALERTS: 'welcome_checklist_setup_alerts_skipped',
|
||||
SETUP_SAVED_VIEWS: 'welcome_checklist_setup_saved_view_skipped',
|
||||
SETUP_WORKSPACE: 'welcome_checklist_setup_workspace_skipped',
|
||||
ADD_DATA_SOURCE: 'welcome_checklist_add_data_source_skipped',
|
||||
};
|
||||
|
||||
export const DOCS_LINKS = {
|
||||
|
||||
@@ -114,7 +114,6 @@ function LogsExplorerViews({
|
||||
|
||||
// Context
|
||||
const {
|
||||
initialDataSource,
|
||||
currentQuery,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
@@ -144,7 +143,7 @@ function LogsExplorerViews({
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: initialDataSource || DataSource.LOGS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logsQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_qu
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { SELECTED_VIEWS } 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 } from 'tests/test-utils';
|
||||
@@ -87,6 +88,25 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock usePreferenceSync
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/logs/useCopyLogLink', () => ({
|
||||
useCopyLogLink: jest.fn().mockReturnValue({
|
||||
activeLogId: ACTIVE_LOG_ID,
|
||||
@@ -105,13 +125,15 @@ const renderer = (): RenderResult =>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<LogsExplorerViews
|
||||
selectedView={SELECTED_VIEWS.SEARCH}
|
||||
showFrequencyChart
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
/>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={SELECTED_VIEWS.SEARCH}
|
||||
showFrequencyChart
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -184,13 +206,15 @@ describe('LogsExplorerViews -', () => {
|
||||
lodsQueryServerRequest();
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue}>
|
||||
<LogsExplorerViews
|
||||
selectedView={SELECTED_VIEWS.SEARCH}
|
||||
showFrequencyChart
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
/>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorerViews
|
||||
selectedView={SELECTED_VIEWS.SEARCH}
|
||||
showFrequencyChart
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'ReactI18';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -108,11 +109,13 @@ describe('LogsPanelComponent', () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import './Explorer.styles.scss';
|
||||
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
@@ -10,7 +11,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -18,6 +19,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import QuerySection from './QuerySection';
|
||||
import TimeSeries from './TimeSeries';
|
||||
import { ExplorerTabs } from './types';
|
||||
@@ -93,6 +95,12 @@ function Explorer(): JSX.Element {
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
@@ -6,6 +7,8 @@ import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQ
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
@@ -19,7 +22,15 @@ function QuerySection(): JSX.Element {
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<Button onClick={(): void => handleRunQuery()} type="primary">
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MetricsExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Card, Tooltip, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
|
||||
import classNames from 'classnames';
|
||||
import ResizeTable from 'components/ResizeTable/ResizeTable';
|
||||
@@ -11,6 +12,7 @@ import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { ArrowDownCircle, ArrowRightCircle, Focus } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import {
|
||||
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
|
||||
TIME_AGGREGATION_OPTIONS,
|
||||
@@ -39,6 +41,21 @@ function ExpandedView({
|
||||
setSelectedTimeSeries,
|
||||
] = useState<InspectMetricsSeries | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.InspectPointClicked, {
|
||||
[MetricsExplorerEventKeys.Modal]: 'inspect',
|
||||
[MetricsExplorerEventKeys.Filters]: metricInspectionOptions.filters,
|
||||
[MetricsExplorerEventKeys.TimeAggregationInterval]:
|
||||
metricInspectionOptions.timeAggregationInterval,
|
||||
[MetricsExplorerEventKeys.TimeAggregationOption]:
|
||||
metricInspectionOptions.timeAggregationOption,
|
||||
[MetricsExplorerEventKeys.SpaceAggregationOption]:
|
||||
metricInspectionOptions.spaceAggregationOption,
|
||||
[MetricsExplorerEventKeys.SpaceAggregationLabels]:
|
||||
metricInspectionOptions.spaceAggregationLabels,
|
||||
});
|
||||
}, [metricInspectionOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== InspectionStep.COMPLETED) {
|
||||
setSelectedTimeSeries(options?.timeSeries ?? null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Skeleton, Switch, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -8,6 +9,7 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import { METRIC_TYPE_TO_COLOR_MAP, METRIC_TYPE_TO_ICON_MAP } from './constants';
|
||||
import GraphPopover from './GraphPopover';
|
||||
@@ -203,7 +205,14 @@ function GraphView({
|
||||
<div className="view-toggle-button">
|
||||
<Switch
|
||||
checked={viewType === 'graph'}
|
||||
onChange={(checked): void => setViewType(checked ? 'graph' : 'table')}
|
||||
onChange={(checked): void => {
|
||||
const newViewType = checked ? 'graph' : 'table';
|
||||
setViewType(newViewType);
|
||||
logEvent(MetricsExplorerEvents.InspectViewChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'inspect',
|
||||
[MetricsExplorerEventKeys.InspectView]: newViewType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>
|
||||
{viewType === 'graph' ? 'Graph View' : 'Table View'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import './Inspect.styles.scss';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -11,11 +12,16 @@ import { Compass } from 'lucide-react';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import ExpandedView from './ExpandedView';
|
||||
import GraphView from './GraphView';
|
||||
import QueryBuilder from './QueryBuilder';
|
||||
import Stepper from './Stepper';
|
||||
import { GraphPopoverOptions, InspectProps } from './types';
|
||||
import {
|
||||
GraphPopoverOptions,
|
||||
InspectProps,
|
||||
MetricInspectionAction,
|
||||
} from './types';
|
||||
import { useInspectMetrics } from './useInspectMetrics';
|
||||
|
||||
function Inspect({
|
||||
@@ -92,6 +98,25 @@ function Inspect({
|
||||
reset,
|
||||
} = useInspectMetrics(metricName);
|
||||
|
||||
const handleDispatchMetricInspectionOptions = useCallback(
|
||||
(action: MetricInspectionAction): void => {
|
||||
dispatchMetricInspectionOptions(action);
|
||||
logEvent(MetricsExplorerEvents.InspectQueryChanged, {
|
||||
[MetricsExplorerEventKeys.Modal]: 'inspect',
|
||||
[MetricsExplorerEventKeys.Filters]: metricInspectionOptions.filters,
|
||||
[MetricsExplorerEventKeys.TimeAggregationInterval]:
|
||||
metricInspectionOptions.timeAggregationInterval,
|
||||
[MetricsExplorerEventKeys.TimeAggregationOption]:
|
||||
metricInspectionOptions.timeAggregationOption,
|
||||
[MetricsExplorerEventKeys.SpaceAggregationOption]:
|
||||
metricInspectionOptions.spaceAggregationOption,
|
||||
[MetricsExplorerEventKeys.SpaceAggregationLabels]:
|
||||
metricInspectionOptions.spaceAggregationLabels,
|
||||
});
|
||||
},
|
||||
[dispatchMetricInspectionOptions, metricInspectionOptions],
|
||||
);
|
||||
|
||||
const selectedMetricType = useMemo(
|
||||
() => metricDetailsData?.payload?.data?.metadata?.metric_type,
|
||||
[metricDetailsData],
|
||||
@@ -186,7 +211,7 @@ function Inspect({
|
||||
setMetricName={setMetricName}
|
||||
spaceAggregationLabels={spaceAggregationLabels}
|
||||
metricInspectionOptions={metricInspectionOptions}
|
||||
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
|
||||
dispatchMetricInspectionOptions={handleDispatchMetricInspectionOptions}
|
||||
inspectionStep={inspectionStep}
|
||||
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
|
||||
searchQuery={searchQuery}
|
||||
@@ -227,12 +252,18 @@ function Inspect({
|
||||
popoverOptions,
|
||||
metricInspectionOptions,
|
||||
spaceAggregationLabels,
|
||||
dispatchMetricInspectionOptions,
|
||||
handleDispatchMetricInspectionOptions,
|
||||
searchQuery,
|
||||
expandedViewOptions,
|
||||
timeAggregatedSeriesMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
[MetricsExplorerEventKeys.Modal]: 'inspect',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<Drawer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Card, Input, Select, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import classNames from 'classnames';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import {
|
||||
SPACE_AGGREGATION_OPTIONS,
|
||||
TIME_AGGREGATION_OPTIONS,
|
||||
@@ -135,6 +137,9 @@ export function MetricFilters({
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
handleChangeQueryData('filters', value);
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
[MetricsExplorerEventKeys.Modal]: 'inspect',
|
||||
});
|
||||
dispatchMetricInspectionOptions({
|
||||
type: 'SET_FILTERS',
|
||||
payload: value,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -10,6 +11,7 @@ import { useCopyToClipboard } from 'react-use';
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { AllAttributesProps, AllAttributesValueProps } from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
@@ -135,9 +137,16 @@ function AllAttributes({
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metricType, {
|
||||
@@ -153,6 +162,13 @@ function AllAttributes({
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
[MetricsExplorerEventKeys.AttributeKey]: key,
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Collapse, Input, Select, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
@@ -11,6 +12,7 @@ import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import {
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
@@ -170,6 +172,11 @@ function Metadata({
|
||||
{
|
||||
onSuccess: (response): void => {
|
||||
if (response?.statusCode === 200) {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
|
||||
@@ -11,14 +11,16 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { isInspectEnabled } from '../Inspect/utils';
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import AllAttributes from './AllAttributes';
|
||||
@@ -95,11 +97,22 @@ function MetricDetails({
|
||||
},
|
||||
ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
|
||||
|
||||
const isMetricDetailsError = metricDetailsError || !metric;
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
|
||||
@@ -10,24 +10,21 @@ import {
|
||||
} from 'antd';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { COMPOSITE_QUERY_KEY } from './constants';
|
||||
import { SUMMARY_FILTERS_KEY } from './constants';
|
||||
|
||||
function MetricNameSearch(): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
function MetricNameSearch({
|
||||
queryFilters,
|
||||
}: {
|
||||
queryFilters: TagFilter;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
@@ -70,9 +67,9 @@ function MetricNameSearch(): JSX.Element {
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricName: string): void => {
|
||||
const newFilter = {
|
||||
const newFilters = {
|
||||
items: [
|
||||
...currentQuery.builder.queryData[0].filters.items,
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'metric_name',
|
||||
op: 'CONTAINS',
|
||||
@@ -84,27 +81,15 @@ function MetricNameSearch(): JSX.Element {
|
||||
value: selectedMetricName,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
op: 'and',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[currentQuery, handleChangeQueryData, setSearchParams],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const metricNameFilterValues = useMemo(
|
||||
@@ -198,7 +183,7 @@ function MetricNameSearch(): JSX.Element {
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
const value = e.target.value.trim();
|
||||
setSearchString(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { Button, Menu, Popover, Tooltip } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
COMPOSITE_QUERY_KEY,
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
SUMMARY_FILTERS_KEY,
|
||||
} from './constants';
|
||||
|
||||
function MetricTypeSearch(): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
function MetricTypeSearch({
|
||||
queryFilters,
|
||||
}: {
|
||||
queryFilters: TagFilter;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const menuItems = useMemo(
|
||||
@@ -40,9 +37,9 @@ function MetricTypeSearch(): JSX.Element {
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricType: string): void => {
|
||||
if (selectedMetricType !== 'all') {
|
||||
const newFilter = {
|
||||
const newFilters = {
|
||||
items: [
|
||||
...currentQuery.builder.queryData[0].filters.items,
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'metric_type',
|
||||
op: '=',
|
||||
@@ -56,49 +53,23 @@ function MetricTypeSearch(): JSX.Element {
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
} else {
|
||||
const newFilter = {
|
||||
items: currentQuery.builder.queryData[0].filters.items.filter(
|
||||
(item) => item.id !== 'metric_type',
|
||||
),
|
||||
const newFilters = {
|
||||
items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
|
||||
op: 'AND',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
}
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[currentQuery, handleChangeQueryData, setSearchParams],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const menu = (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Info } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { MetricsListItemRowData, MetricsTableProps } from './types';
|
||||
import { metricsTableColumns } from './utils';
|
||||
import { getMetricsTableColumns } from './utils';
|
||||
|
||||
function MetricsTable({
|
||||
isLoading,
|
||||
@@ -24,6 +24,7 @@ function MetricsTable({
|
||||
setOrderBy,
|
||||
totalCount,
|
||||
openMetricDetails,
|
||||
queryFilters,
|
||||
}: MetricsTableProps): JSX.Element {
|
||||
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
|
||||
(
|
||||
@@ -74,7 +75,7 @@ function MetricsTable({
|
||||
),
|
||||
}}
|
||||
dataSource={data}
|
||||
columns={metricsTableColumns}
|
||||
columns={getMetricsTableColumns(queryFilters)}
|
||||
locale={{
|
||||
emptyText: isLoading ? null : (
|
||||
<div
|
||||
@@ -107,7 +108,7 @@ function MetricsTable({
|
||||
total: totalCount,
|
||||
}}
|
||||
onRow={(record): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => openMetricDetails(record.key),
|
||||
onClick: (): void => openMetricDetails(record.key, 'list'),
|
||||
className: 'clickable-row',
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -154,7 +154,7 @@ function MetricsTreemap({
|
||||
<foreignObject
|
||||
width={nodeWidth}
|
||||
height={nodeHeight}
|
||||
onClick={(): void => openMetricDetails(node.data.id)}
|
||||
onClick={(): void => openMetricDetails(node.data.id, 'treemap')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import './Summary.styles.scss';
|
||||
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import InspectModal from '../Inspect';
|
||||
import MetricDetails from '../MetricDetails';
|
||||
import {
|
||||
COMPOSITE_QUERY_KEY,
|
||||
IS_INSPECT_MODAL_OPEN_KEY,
|
||||
IS_METRIC_DETAILS_OPEN_KEY,
|
||||
SELECTED_METRIC_NAME_KEY,
|
||||
SUMMARY_FILTERS_KEY,
|
||||
} from './constants';
|
||||
import MetricsSearch from './MetricsSearch';
|
||||
import MetricsTable from './MetricsTable';
|
||||
@@ -63,57 +61,37 @@ function Summary(): JSX.Element {
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { currentQuery, updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
const defaultQuery = useMemo(() => {
|
||||
const query = updateAllQueriesOperators(
|
||||
initialQueriesMap.metrics,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.METRICS,
|
||||
);
|
||||
|
||||
const queryFilters: TagFilter = useMemo(() => {
|
||||
const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY);
|
||||
if (encodedFilters) {
|
||||
return JSON.parse(encodedFilters);
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...query.builder.queryData[0],
|
||||
orderBy: [DEFAULT_ORDER_BY],
|
||||
},
|
||||
],
|
||||
},
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
}, [updateAllQueriesOperators]);
|
||||
}, [searchParams]);
|
||||
|
||||
useShareBuilderUrl(defaultQuery);
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.TimeUpdated, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
});
|
||||
}, [maxTime, minTime]);
|
||||
|
||||
// This is used to avoid the filters from being serialized with the id
|
||||
const currentQueryFiltersString = useMemo(() => {
|
||||
const filters = currentQuery?.builder?.queryData[0]?.filters;
|
||||
if (!filters) return '';
|
||||
const queryFiltersWithoutId = useMemo(() => {
|
||||
const filtersWithoutId = {
|
||||
...filters,
|
||||
items: filters.items.map(({ id, ...rest }) => rest),
|
||||
...queryFilters,
|
||||
items: queryFilters.items.map(({ id, ...rest }) => rest),
|
||||
};
|
||||
return JSON.stringify(filtersWithoutId);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryFilters = useMemo(
|
||||
() =>
|
||||
currentQuery?.builder?.queryData[0]?.filters || {
|
||||
items: [],
|
||||
op: 'and',
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentQueryFiltersString],
|
||||
);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
}, [queryFilters]);
|
||||
|
||||
const metricsListQuery = useMemo(() => {
|
||||
const baseQuery = getMetricsListQuery();
|
||||
@@ -146,6 +124,15 @@ function Summary(): JSX.Element {
|
||||
isError: isMetricsError,
|
||||
} = useGetMetricsList(metricsListQuery, {
|
||||
enabled: !!metricsListQuery && !isInspectModalOpen,
|
||||
queryKey: [
|
||||
'metricsList',
|
||||
queryFiltersWithoutId,
|
||||
orderBy,
|
||||
pageSize,
|
||||
currentPage,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
|
||||
const isListViewError = useMemo(
|
||||
@@ -160,6 +147,13 @@ function Summary(): JSX.Element {
|
||||
isError: isTreeMapError,
|
||||
} = useGetMetricsTreeMap(metricsTreemapQuery, {
|
||||
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
|
||||
queryKey: [
|
||||
'metricsTreemap',
|
||||
queryFiltersWithoutId,
|
||||
heatmapView,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
|
||||
const isProportionViewError = useMemo(
|
||||
@@ -169,51 +163,37 @@ function Summary(): JSX.Element {
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter) => {
|
||||
handleChangeQueryData('filters', value);
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: value,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(value),
|
||||
});
|
||||
setCurrentPage(1);
|
||||
logEvent(MetricsExplorerEvents.FilterApplied, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
});
|
||||
},
|
||||
[handleChangeQueryData, currentQuery, setSearchParams],
|
||||
[setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
const searchQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
filters: queryFilters,
|
||||
}),
|
||||
[currentQuery],
|
||||
[queryFilters],
|
||||
);
|
||||
|
||||
const searchQuery = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const onPaginationChange = (page: number, pageSize: number): void => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(pageSize);
|
||||
logEvent(MetricsExplorerEvents.PageNumberChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.PageNumber]: page,
|
||||
});
|
||||
logEvent(MetricsExplorerEvents.PageSizeChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.PageSize]: pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
const formattedMetricsData = useMemo(
|
||||
@@ -221,19 +201,28 @@ function Summary(): JSX.Element {
|
||||
[metricsData],
|
||||
);
|
||||
|
||||
const openMetricDetails = (metricName: string): void => {
|
||||
const openMetricDetails = (
|
||||
metricName: string,
|
||||
view: 'list' | 'treemap',
|
||||
): void => {
|
||||
setSelectedMetricName(metricName);
|
||||
setIsMetricDetailsOpen(true);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[IS_METRIC_DETAILS_OPEN_KEY]: 'true',
|
||||
[SELECTED_METRIC_NAME_KEY]: metricName,
|
||||
});
|
||||
logEvent(MetricsExplorerEvents.MetricClicked, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.View]: view,
|
||||
});
|
||||
};
|
||||
|
||||
const closeMetricDetails = (): void => {
|
||||
setSelectedMetricName(null);
|
||||
setIsMetricDetailsOpen(false);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[IS_METRIC_DETAILS_OPEN_KEY]: 'false',
|
||||
[SELECTED_METRIC_NAME_KEY]: '',
|
||||
});
|
||||
@@ -244,24 +233,39 @@ function Summary(): JSX.Element {
|
||||
setIsInspectModalOpen(true);
|
||||
setIsMetricDetailsOpen(false);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[IS_INSPECT_MODAL_OPEN_KEY]: 'true',
|
||||
[SELECTED_METRIC_NAME_KEY]: metricName,
|
||||
});
|
||||
};
|
||||
|
||||
const closeInspectModal = (): void => {
|
||||
handleChangeQueryData('filters', {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
});
|
||||
setIsInspectModalOpen(false);
|
||||
setSelectedMetricName(null);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[IS_INSPECT_MODAL_OPEN_KEY]: 'false',
|
||||
[SELECTED_METRIC_NAME_KEY]: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetHeatmapView = (view: TreemapViewType): void => {
|
||||
setHeatmapView(view);
|
||||
logEvent(MetricsExplorerEvents.TreemapViewChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.ViewType]: view,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetOrderBy = (orderBy: OrderByPayload): void => {
|
||||
setOrderBy(orderBy);
|
||||
logEvent(MetricsExplorerEvents.OrderByApplied, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.ColumnName]: orderBy.columnName,
|
||||
[MetricsExplorerEventKeys.Order]: orderBy.order,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
@@ -272,7 +276,7 @@ function Summary(): JSX.Element {
|
||||
isError={isProportionViewError}
|
||||
viewType={heatmapView}
|
||||
openMetricDetails={openMetricDetails}
|
||||
setHeatmapView={setHeatmapView}
|
||||
setHeatmapView={handleSetHeatmapView}
|
||||
/>
|
||||
<MetricsTable
|
||||
isLoading={isMetricsLoading || isMetricsFetching}
|
||||
@@ -281,9 +285,10 @@ function Summary(): JSX.Element {
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onPaginationChange={onPaginationChange}
|
||||
setOrderBy={setOrderBy}
|
||||
setOrderBy={handleSetOrderBy}
|
||||
totalCount={metricsData?.payload?.data?.total || 0}
|
||||
openMetricDetails={openMetricDetails}
|
||||
queryFilters={queryFilters}
|
||||
/>
|
||||
</div>
|
||||
{isMetricDetailsOpen && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuil
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import MetricsTable from '../MetricsTable';
|
||||
import { MetricsListItemRowData } from '../types';
|
||||
@@ -29,6 +30,11 @@ const mockData: MetricsListItemRowData[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockQueryFilters: TagFilter = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => {
|
||||
const actual = jest.requireActual('react-router-dom-v5-compat');
|
||||
return {
|
||||
@@ -76,6 +82,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -99,6 +106,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
isLoading
|
||||
/>
|
||||
</Provider>
|
||||
@@ -122,6 +130,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -149,6 +158,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -177,13 +187,14 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={jest.fn()}
|
||||
totalCount={2}
|
||||
openMetricDetails={mockOpenMetricDetails}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Metric 1'));
|
||||
expect(mockOpenMetricDetails).toHaveBeenCalledWith('metric1');
|
||||
expect(mockOpenMetricDetails).toHaveBeenCalledWith('metric1', 'list');
|
||||
});
|
||||
|
||||
it('calls setOrderBy when column header is clicked', () => {
|
||||
@@ -201,6 +212,7 @@ describe('MetricsTable', () => {
|
||||
setOrderBy={mockSetOrderBy}
|
||||
totalCount={2}
|
||||
openMetricDetails={jest.fn()}
|
||||
queryFilters={mockQueryFilters}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
|
||||
@@ -1,45 +1,61 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { TreemapViewType } from '../types';
|
||||
import {
|
||||
formatDataForMetricsTable,
|
||||
metricsTableColumns,
|
||||
getMetricsTableColumns,
|
||||
MetricTypeRenderer,
|
||||
} from '../utils';
|
||||
|
||||
describe('metricsTableColumns', () => {
|
||||
const mockQueryFilters: TagFilter = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
it('should have correct column definitions', () => {
|
||||
expect(metricsTableColumns).toHaveLength(6);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6);
|
||||
|
||||
// Metric Name column
|
||||
expect(metricsTableColumns[0].dataIndex).toBe('metric_name');
|
||||
expect(metricsTableColumns[0].width).toBe(400);
|
||||
expect(metricsTableColumns[0].sorter).toBe(false);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe(
|
||||
'metric_name',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false);
|
||||
|
||||
// Description column
|
||||
expect(metricsTableColumns[1].dataIndex).toBe('description');
|
||||
expect(metricsTableColumns[1].width).toBe(400);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe(
|
||||
'description',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400);
|
||||
|
||||
// Type column
|
||||
expect(metricsTableColumns[2].dataIndex).toBe('metric_type');
|
||||
expect(metricsTableColumns[2].width).toBe(150);
|
||||
expect(metricsTableColumns[2].sorter).toBe(false);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe(
|
||||
'metric_type',
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false);
|
||||
|
||||
// Unit column
|
||||
expect(metricsTableColumns[3].dataIndex).toBe('unit');
|
||||
expect(metricsTableColumns[3].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit');
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150);
|
||||
|
||||
// Samples column
|
||||
expect(metricsTableColumns[4].dataIndex).toBe(TreemapViewType.SAMPLES);
|
||||
expect(metricsTableColumns[4].width).toBe(150);
|
||||
expect(metricsTableColumns[4].sorter).toBe(true);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe(
|
||||
TreemapViewType.SAMPLES,
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true);
|
||||
|
||||
// Time Series column
|
||||
expect(metricsTableColumns[5].dataIndex).toBe(TreemapViewType.TIMESERIES);
|
||||
expect(metricsTableColumns[5].width).toBe(150);
|
||||
expect(metricsTableColumns[5].sorter).toBe(true);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe(
|
||||
TreemapViewType.TIMESERIES,
|
||||
);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150);
|
||||
expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true);
|
||||
});
|
||||
|
||||
describe('MetricTypeRenderer', () => {
|
||||
|
||||
@@ -36,4 +36,4 @@ export const METRIC_TYPE_VALUES_MAP = {
|
||||
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
|
||||
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
|
||||
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
|
||||
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
|
||||
export const SUMMARY_FILTERS_KEY = 'summaryFilters';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
import React, { Dispatch, SetStateAction } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
@@ -12,9 +12,10 @@ export interface MetricsTableProps {
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
onPaginationChange: (page: number, pageSize: number) => void;
|
||||
setOrderBy: Dispatch<SetStateAction<OrderByPayload>>;
|
||||
setOrderBy: (orderBy: OrderByPayload) => void;
|
||||
totalCount: number;
|
||||
openMetricDetails: (metricName: string) => void;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
queryFilters: TagFilter;
|
||||
}
|
||||
|
||||
export interface MetricsSearchProps {
|
||||
@@ -27,7 +28,7 @@ export interface MetricsTreemapProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
viewType: TreemapViewType;
|
||||
openMetricDetails: (metricName: string) => void;
|
||||
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
|
||||
setHeatmapView: (value: TreemapViewType) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,18 +18,21 @@ import {
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
||||
import MetricNameSearch from './MetricNameSearch';
|
||||
import MetricTypeSearch from './MetricTypeSearch';
|
||||
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
|
||||
|
||||
export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
|
||||
export const getMetricsTableColumns = (
|
||||
queryFilters: TagFilter,
|
||||
): ColumnType<MetricsListItemRowData>[] => [
|
||||
{
|
||||
title: (
|
||||
<div className="metric-name-column-header">
|
||||
<span className="metric-name-column-header-text">METRIC</span>
|
||||
<MetricNameSearch />
|
||||
<MetricNameSearch queryFilters={queryFilters} />
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'metric_name',
|
||||
@@ -51,7 +54,7 @@ export const metricsTableColumns: ColumnType<MetricsListItemRowData>[] = [
|
||||
title: (
|
||||
<div className="metric-type-column-header">
|
||||
<span className="metric-type-column-header-text">TYPE</span>
|
||||
<MetricTypeSearch />
|
||||
<MetricTypeSearch queryFilters={queryFilters} />
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'metric_type',
|
||||
|
||||
51
frontend/src/container/MetricsExplorer/events.ts
Normal file
51
frontend/src/container/MetricsExplorer/events.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* This file contains all analytics events for the Metrics Explorer.
|
||||
*/
|
||||
export enum MetricsExplorerEvents {
|
||||
TabChanged = 'Metrics Explorer: Tab visited',
|
||||
ModalOpened = 'Metrics Explorer: Modal opened',
|
||||
MetricClicked = 'Metrics Explorer: Metric clicked',
|
||||
FilterApplied = 'Metrics Explorer: Filter applied',
|
||||
TimeUpdated = 'Metrics Explorer: Time updated',
|
||||
TreemapViewChanged = 'Metrics Explorer: Treemap view changed',
|
||||
PageNumberChanged = 'Metrics Explorer: Page number changed',
|
||||
PageSizeChanged = 'Metrics Explorer: Page size changed',
|
||||
OrderByApplied = 'Metrics Explorer: Order by applied',
|
||||
MetricMetadataUpdated = 'Metrics Explorer: Metric metadata updated',
|
||||
OpenInExplorerClicked = 'Metrics Explorer: Open in explorer clicked',
|
||||
InspectViewChanged = 'Metrics Explorer: Inspect view changed',
|
||||
InspectQueryChanged = 'Metrics Explorer: Inspect query changed',
|
||||
InspectPointClicked = 'Metrics Explorer: Inspect point clicked',
|
||||
QueryBuilderQueryChanged = 'Metrics Explorer: QueryBuilder query changed',
|
||||
YAxisUnitApplied = 'Metrics Explorer: Y axis unit applied',
|
||||
AddToAlertClicked = 'Metrics Explorer: Add to alert clicked',
|
||||
AddToDashboardClicked = 'Metrics Explorer: Add to dashboard clicked',
|
||||
SaveViewClicked = 'Metrics Explorer: Save view clicked',
|
||||
SearchApplied = 'Metrics Explorer: Search applied',
|
||||
ViewEdited = 'Metrics Explorer: View edited',
|
||||
ViewDeleted = 'Metrics Explorer: View deleted',
|
||||
}
|
||||
|
||||
export enum MetricsExplorerEventKeys {
|
||||
Tab = 'tab',
|
||||
Modal = 'modal',
|
||||
View = 'view',
|
||||
Interval = 'interval',
|
||||
ViewType = 'viewType',
|
||||
PageNumber = 'pageNumber',
|
||||
PageSize = 'pageSize',
|
||||
ColumnName = 'columnName',
|
||||
Order = 'order',
|
||||
AttributeKey = 'attributeKey',
|
||||
AttributeValue = 'attributeValue',
|
||||
MetricName = 'metricName',
|
||||
InspectView = 'inspectView',
|
||||
TimeAggregationOption = 'timeAggregationOption',
|
||||
TimeAggregationInterval = 'timeAggregationInterval',
|
||||
SpaceAggregationOption = 'spaceAggregationOption',
|
||||
SpaceAggregationLabels = 'spaceAggregationLabels',
|
||||
OneChartPerQueryEnabled = 'oneChartPerQueryEnabled',
|
||||
YAxisUnit = 'yAxisUnit',
|
||||
ViewName = 'viewName',
|
||||
Filters = 'filters',
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import './OnboardingQuestionaire.styles.scss';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateProfileAPI from 'api/onboarding/updateProfile';
|
||||
import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences';
|
||||
import updateOrgPreferenceAPI from 'api/preferences/updateOrgPreference';
|
||||
import listOrgPreferences from 'api/v1/org/preferences/list';
|
||||
import updateOrgPreferenceAPI from 'api/v1/org/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -108,13 +108,13 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const { refetch: refetchOrgPreferences } = useQuery({
|
||||
queryFn: () => getAllOrgPreferences(),
|
||||
queryFn: () => listOrgPreferences(),
|
||||
queryKey: ['getOrgPreferences'],
|
||||
enabled: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (response) => {
|
||||
if (response.payload && response.payload.data) {
|
||||
updateOrgPreferences(response.payload.data);
|
||||
if (response.data) {
|
||||
updateOrgPreferences(response.data);
|
||||
}
|
||||
|
||||
setUpdatingOrgOnboardingStatus(false);
|
||||
@@ -196,7 +196,7 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
|
||||
setUpdatingOrgOnboardingStatus(true);
|
||||
updateOrgPreference({
|
||||
preferenceID: 'ORG_ONBOARDING',
|
||||
name: 'org_onboarding',
|
||||
value: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
@@ -11,6 +8,7 @@ import {
|
||||
AllTraceFilterKeys,
|
||||
AllTraceFilterKeyValue,
|
||||
} from 'pages/TracesExplorer/Filter/filterUtils';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
@@ -35,10 +33,10 @@ import {
|
||||
import { getOptionsFromKeys } from './utils';
|
||||
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
initialOptions?: InitialOptions;
|
||||
storageKey: LOCALSTORAGE;
|
||||
}
|
||||
|
||||
interface UseOptionsMenu {
|
||||
@@ -48,22 +46,21 @@ interface UseOptionsMenu {
|
||||
}
|
||||
|
||||
const useOptionsMenu = ({
|
||||
storageKey,
|
||||
dataSource,
|
||||
aggregateOperator,
|
||||
initialOptions = {},
|
||||
}: UseOptionsMenuProps): UseOptionsMenu => {
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
preferences,
|
||||
updateColumns,
|
||||
updateFormatting,
|
||||
} = usePreferenceContext();
|
||||
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
const debouncedSearchText = useDebounce(searchText, 300);
|
||||
|
||||
const localStorageOptionsQuery = useMemo(
|
||||
() => getFromLocalstorage(storageKey),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const initialQueryParams = useMemo(
|
||||
() => ({
|
||||
searchText: '',
|
||||
@@ -77,7 +74,6 @@ const useOptionsMenu = ({
|
||||
|
||||
const {
|
||||
query: optionsQuery,
|
||||
queryData: optionsQueryData,
|
||||
redirectWithQuery: redirectWithOptionsData,
|
||||
} = useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
|
||||
|
||||
@@ -105,7 +101,9 @@ const useOptionsMenu = ({
|
||||
);
|
||||
|
||||
const initialSelectedColumns = useMemo(() => {
|
||||
if (!isFetchedInitialAttributes) return [];
|
||||
if (!isFetchedInitialAttributes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attributesData = initialAttributesResult?.reduce(
|
||||
(acc, attributeResponse) => {
|
||||
@@ -142,14 +140,12 @@ const useOptionsMenu = ({
|
||||
})
|
||||
.filter(Boolean) as BaseAutocompleteData[];
|
||||
|
||||
// this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns
|
||||
if (!initialSelected || !initialSelected?.length) {
|
||||
initialSelected = defaultTraceSelectedColumns;
|
||||
}
|
||||
}
|
||||
|
||||
return initialSelected || [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isFetchedInitialAttributes,
|
||||
initialOptions?.selectColumns,
|
||||
@@ -171,7 +167,6 @@ const useOptionsMenu = ({
|
||||
const searchedAttributeKeys = useMemo(() => {
|
||||
if (searchedAttributesData?.payload?.attributeKeys?.length) {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
// add timestamp and body to the list of attributes
|
||||
return [
|
||||
...defaultLogsSelectedColumns,
|
||||
...searchedAttributesData.payload.attributeKeys.filter(
|
||||
@@ -188,32 +183,35 @@ const useOptionsMenu = ({
|
||||
return [];
|
||||
}, [dataSource, searchedAttributesData?.payload?.attributeKeys]);
|
||||
|
||||
const initialOptionsQuery: OptionsQuery = useMemo(
|
||||
() => ({
|
||||
const initialOptionsQuery: OptionsQuery = useMemo(() => {
|
||||
let defaultColumns = defaultOptionsQuery.selectColumns;
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
defaultColumns = defaultTraceSelectedColumns;
|
||||
} else if (dataSource === DataSource.LOGS) {
|
||||
defaultColumns = defaultLogsSelectedColumns;
|
||||
}
|
||||
|
||||
const finalSelectColumns = initialOptions?.selectColumns
|
||||
? initialSelectedColumns
|
||||
: defaultColumns;
|
||||
|
||||
return {
|
||||
...defaultOptionsQuery,
|
||||
...initialOptions,
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
selectColumns: initialOptions?.selectColumns
|
||||
? initialSelectedColumns
|
||||
: dataSource === DataSource.TRACES
|
||||
? defaultTraceSelectedColumns
|
||||
: defaultOptionsQuery.selectColumns,
|
||||
}),
|
||||
[dataSource, initialOptions, initialSelectedColumns],
|
||||
);
|
||||
selectColumns: finalSelectColumns,
|
||||
};
|
||||
}, [dataSource, initialOptions, initialSelectedColumns]);
|
||||
|
||||
const selectedColumnKeys = useMemo(
|
||||
() => optionsQueryData?.selectColumns?.map(({ id }) => id) || [],
|
||||
[optionsQueryData],
|
||||
() => preferences?.columns?.map(({ id }) => id) || [],
|
||||
[preferences?.columns],
|
||||
);
|
||||
|
||||
const optionsFromAttributeKeys = useMemo(() => {
|
||||
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
|
||||
// For other data sources, only filter out 'body' if it exists
|
||||
if (dataSource !== DataSource.LOGS) {
|
||||
return item.key !== 'body';
|
||||
}
|
||||
// For LOGS, keep all keys
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -223,10 +221,8 @@ const useOptionsMenu = ({
|
||||
const handleRedirectWithOptionsData = useCallback(
|
||||
(newQueryData: OptionsQuery) => {
|
||||
redirectWithOptionsData(newQueryData);
|
||||
|
||||
setToLocalstorage(storageKey, JSON.stringify(newQueryData));
|
||||
},
|
||||
[storageKey, redirectWithOptionsData],
|
||||
[redirectWithOptionsData],
|
||||
);
|
||||
|
||||
const handleSelectColumns = useCallback(
|
||||
@@ -235,7 +231,7 @@ const useOptionsMenu = ({
|
||||
const newSelectedColumns = newSelectedColumnKeys.reduce((acc, key) => {
|
||||
const column = [
|
||||
...searchedAttributeKeys,
|
||||
...optionsQueryData.selectColumns,
|
||||
...(preferences?.columns || []),
|
||||
].find(({ id }) => id === key);
|
||||
|
||||
if (!column) return acc;
|
||||
@@ -243,75 +239,116 @@ const useOptionsMenu = ({
|
||||
}, [] as BaseAutocompleteData[]);
|
||||
|
||||
const optionsData: OptionsQuery = {
|
||||
...optionsQueryData,
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newSelectedColumns,
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
|
||||
updateColumns(newSelectedColumns);
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
},
|
||||
[
|
||||
searchedAttributeKeys,
|
||||
selectedColumnKeys,
|
||||
optionsQueryData,
|
||||
preferences,
|
||||
handleRedirectWithOptionsData,
|
||||
updateColumns,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = optionsQueryData?.selectColumns?.filter(
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
({ id }) => id !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns.length && dataSource !== DataSource.LOGS) {
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
notifications.error({
|
||||
message: 'There must be at least one selected column',
|
||||
});
|
||||
} else {
|
||||
const optionsData: OptionsQuery = {
|
||||
...optionsQueryData,
|
||||
selectColumns: newSelectedColumns,
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newSelectedColumns || [],
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines:
|
||||
preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize:
|
||||
preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
|
||||
updateColumns(newSelectedColumns || []);
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
}
|
||||
},
|
||||
[dataSource, notifications, optionsQueryData, handleRedirectWithOptionsData],
|
||||
[
|
||||
dataSource,
|
||||
notifications,
|
||||
preferences,
|
||||
handleRedirectWithOptionsData,
|
||||
updateColumns,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFormatChange = useCallback(
|
||||
(value: LogViewMode) => {
|
||||
const optionsData: OptionsQuery = {
|
||||
...optionsQueryData,
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: preferences?.columns || [],
|
||||
format: value,
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
|
||||
updateFormatting({
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
format: value,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
});
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
},
|
||||
[handleRedirectWithOptionsData, optionsQueryData],
|
||||
[handleRedirectWithOptionsData, preferences, updateFormatting],
|
||||
);
|
||||
|
||||
const handleMaxLinesChange = useCallback(
|
||||
(value: string | number | null) => {
|
||||
const optionsData: OptionsQuery = {
|
||||
...optionsQueryData,
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: preferences?.columns || [],
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines: value as number,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
|
||||
updateFormatting({
|
||||
maxLines: value as number,
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
});
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
},
|
||||
[handleRedirectWithOptionsData, optionsQueryData],
|
||||
[handleRedirectWithOptionsData, preferences, updateFormatting],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback(
|
||||
(value: FontSize) => {
|
||||
const optionsData: OptionsQuery = {
|
||||
...optionsQueryData,
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: preferences?.columns || [],
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize: value,
|
||||
};
|
||||
|
||||
updateFormatting({
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
fontSize: value,
|
||||
});
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
},
|
||||
[handleRedirectWithOptionsData, optionsQueryData],
|
||||
[handleRedirectWithOptionsData, preferences, updateFormatting],
|
||||
);
|
||||
|
||||
const handleSearchAttribute = useCallback((value: string) => {
|
||||
@@ -331,7 +368,7 @@ const useOptionsMenu = ({
|
||||
() => ({
|
||||
addColumn: {
|
||||
isFetching: isSearchedAttributesFetching,
|
||||
value: optionsQueryData?.selectColumns || defaultOptionsQuery.selectColumns,
|
||||
value: preferences?.columns || defaultOptionsQuery.selectColumns,
|
||||
options: optionsFromAttributeKeys || [],
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
@@ -340,24 +377,21 @@ const useOptionsMenu = ({
|
||||
onSearch: handleSearchAttribute,
|
||||
},
|
||||
format: {
|
||||
value: optionsQueryData.format || defaultOptionsQuery.format,
|
||||
value: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
onChange: handleFormatChange,
|
||||
},
|
||||
maxLines: {
|
||||
value: optionsQueryData.maxLines || defaultOptionsQuery.maxLines,
|
||||
value: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
onChange: handleMaxLinesChange,
|
||||
},
|
||||
fontSize: {
|
||||
value: optionsQueryData?.fontSize || defaultOptionsQuery.fontSize,
|
||||
value: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
onChange: handleFontSizeChange,
|
||||
},
|
||||
}),
|
||||
[
|
||||
isSearchedAttributesFetching,
|
||||
optionsQueryData?.selectColumns,
|
||||
optionsQueryData.format,
|
||||
optionsQueryData.maxLines,
|
||||
optionsQueryData?.fontSize,
|
||||
preferences,
|
||||
optionsFromAttributeKeys,
|
||||
handleSelectColumns,
|
||||
handleRemoveSelectedColumn,
|
||||
@@ -369,23 +403,25 @@ const useOptionsMenu = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (optionsQuery || !isFetchedInitialAttributes) return;
|
||||
if (optionsQuery || !isFetchedInitialAttributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOptionsQuery = localStorageOptionsQuery
|
||||
? JSON.parse(localStorageOptionsQuery)
|
||||
: initialOptionsQuery;
|
||||
|
||||
redirectWithOptionsData(nextOptionsQuery);
|
||||
redirectWithOptionsData(initialOptionsQuery);
|
||||
}, [
|
||||
isFetchedInitialAttributes,
|
||||
optionsQuery,
|
||||
initialOptionsQuery,
|
||||
localStorageOptionsQuery,
|
||||
redirectWithOptionsData,
|
||||
]);
|
||||
|
||||
return {
|
||||
options: optionsQueryData,
|
||||
options: {
|
||||
selectColumns: preferences?.columns || [],
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
},
|
||||
config: optionsMenuConfig,
|
||||
handleOptionsChange: handleRedirectWithOptionsData,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { screen } from '@testing-library/react';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
@@ -19,6 +20,18 @@ jest.mock('uplot', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useUrlQuery hook
|
||||
const mockUrlQuery = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
toString: jest.fn(() => ''),
|
||||
};
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => mockUrlQuery),
|
||||
}));
|
||||
|
||||
const samplePipelinePreviewResponse = {
|
||||
isLoading: false,
|
||||
logs: [
|
||||
@@ -57,17 +70,38 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock usePreferenceSync
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render PipelineListsView section', () => {
|
||||
const { getByText, container } = render(
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="viewing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>,
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="viewing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// table headers assertions
|
||||
@@ -91,14 +125,16 @@ describe('PipelinePage container test', () => {
|
||||
|
||||
it('should render expanded content and edit mode correctly', async () => {
|
||||
const { getByText } = render(
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>,
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// content assertion
|
||||
@@ -122,14 +158,16 @@ describe('PipelinePage container test', () => {
|
||||
|
||||
it('should be able to perform actions and edit on expanded view content', async () => {
|
||||
render(
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>,
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// content assertion
|
||||
@@ -180,14 +218,16 @@ describe('PipelinePage container test', () => {
|
||||
|
||||
it('should be able to toggle and delete pipeline', async () => {
|
||||
const { getByText } = render(
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>,
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType=""
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
const addNewPipelineBtn = getByText('add_new_pipeline');
|
||||
@@ -247,14 +287,16 @@ describe('PipelinePage container test', () => {
|
||||
|
||||
it('should have populated form fields when edit pipeline is clicked', async () => {
|
||||
render(
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType="edit-pipeline"
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>,
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
setActionType={jest.fn()}
|
||||
isActionMode="editing-mode"
|
||||
setActionMode={jest.fn()}
|
||||
pipelineData={pipelineApiResponseMockData}
|
||||
isActionType="edit-pipeline"
|
||||
refetchPipelineLists={jest.fn()}
|
||||
/>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// content assertion
|
||||
|
||||
@@ -324,7 +324,7 @@ export const Query = memo(function Query({
|
||||
]);
|
||||
|
||||
const disableOperatorSelector =
|
||||
!query?.aggregateAttribute.key || query?.aggregateAttribute.key === '';
|
||||
!query?.aggregateAttribute?.key || query?.aggregateAttribute?.key === '';
|
||||
|
||||
const isVersionV4 = version && version === ENTITY_VERSION_V4;
|
||||
|
||||
|
||||
@@ -1037,7 +1037,9 @@ function QueryBuilderSearchV2(
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{!hideSpanScopeSelector && <SpanScopeSelector queryName={query.queryName} />}
|
||||
{!hideSpanScopeSelector && (
|
||||
<SpanScopeSelector query={query} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Select } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
enum SpanScope {
|
||||
@@ -17,7 +21,8 @@ interface SpanFilterConfig {
|
||||
}
|
||||
|
||||
interface SpanScopeSelectorProps {
|
||||
queryName: string;
|
||||
onChange?: (value: TagFilter) => void;
|
||||
query?: IBuilderQuery;
|
||||
}
|
||||
|
||||
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
|
||||
@@ -50,7 +55,10 @@ const SELECT_OPTIONS = [
|
||||
{ value: SpanScope.ENTRYPOINT_SPANS, label: 'Entrypoint Spans' },
|
||||
];
|
||||
|
||||
function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
|
||||
function SpanScopeSelector({
|
||||
onChange,
|
||||
query,
|
||||
}: SpanScopeSelectorProps): JSX.Element {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const [selectedScope, setSelectedScope] = useState<SpanScope>(
|
||||
SpanScope.ALL_SPANS,
|
||||
@@ -60,7 +68,7 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
|
||||
filters: TagFilterItem[] = [],
|
||||
): SpanScope => {
|
||||
const hasFilter = (key: string): boolean =>
|
||||
filters.some(
|
||||
filters?.some(
|
||||
(filter) =>
|
||||
filter.key?.type === 'spanSearchScope' &&
|
||||
filter.key.key === key &&
|
||||
@@ -71,15 +79,19 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
|
||||
if (hasFilter('isEntryPoint')) return SpanScope.ENTRYPOINT_SPANS;
|
||||
return SpanScope.ALL_SPANS;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const queryData = (currentQuery?.builder?.queryData || [])?.find(
|
||||
(item) => item.queryName === queryName,
|
||||
let queryData = (currentQuery?.builder?.queryData || [])?.find(
|
||||
(item) => item.queryName === query?.queryName,
|
||||
);
|
||||
|
||||
if (onChange && query) {
|
||||
queryData = query;
|
||||
}
|
||||
|
||||
const filters = queryData?.filters?.items;
|
||||
const currentScope = getCurrentScopeFromFilters(filters);
|
||||
setSelectedScope(currentScope);
|
||||
}, [currentQuery, queryName]);
|
||||
}, [currentQuery, onChange, query]);
|
||||
|
||||
const handleScopeChange = (newScope: SpanScope): void => {
|
||||
const newQuery = cloneDeep(currentQuery);
|
||||
@@ -108,14 +120,28 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: getUpdatedFilters(item.filters?.items, item.queryName === queryName),
|
||||
items: getUpdatedFilters(
|
||||
item.filters?.items,
|
||||
item.queryName === query?.queryName,
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
redirectWithQueryBuilderData(newQuery);
|
||||
if (onChange && query) {
|
||||
onChange({
|
||||
...query.filters,
|
||||
items: getUpdatedFilters(
|
||||
[...query.filters.items, ...newQuery.builder.queryData[0].filters.items],
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
setSelectedScope(newScope);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(newQuery);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
return (
|
||||
<Select
|
||||
value={selectedScope}
|
||||
@@ -127,4 +153,9 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
SpanScopeSelector.defaultProps = {
|
||||
onChange: undefined,
|
||||
query: undefined,
|
||||
};
|
||||
|
||||
export default SpanScopeSelector;
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import SpanScopeSelector from '../SpanScopeSelector';
|
||||
|
||||
@@ -23,6 +28,13 @@ const createSpanScopeFilter = (key: string): TagFilterItem => ({
|
||||
value: 'true',
|
||||
});
|
||||
|
||||
const createNonScopeFilter = (key: string, value: string): TagFilterItem => ({
|
||||
id: `non-scope-${key}`,
|
||||
key: { key, isColumn: false, type: 'tag' },
|
||||
op: '=',
|
||||
value,
|
||||
});
|
||||
|
||||
const defaultQuery = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
@@ -36,6 +48,12 @@ const defaultQuery = {
|
||||
},
|
||||
};
|
||||
|
||||
const defaultQueryBuilderQuery: IBuilderQuery = {
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
};
|
||||
|
||||
// Helper to create query with filters
|
||||
const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
|
||||
...defaultQuery,
|
||||
@@ -44,6 +62,7 @@ const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
|
||||
queryData: [
|
||||
{
|
||||
...defaultQuery.builder.queryData[0],
|
||||
queryName: 'A',
|
||||
filters: {
|
||||
items: filters,
|
||||
op: 'AND',
|
||||
@@ -54,8 +73,9 @@ const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
|
||||
});
|
||||
|
||||
const renderWithContext = (
|
||||
queryName = 'A',
|
||||
initialQuery = defaultQuery,
|
||||
onChangeProp?: (value: TagFilter) => void,
|
||||
queryProp?: IBuilderQuery,
|
||||
): RenderResult =>
|
||||
render(
|
||||
<QueryBuilderContext.Provider
|
||||
@@ -67,10 +87,24 @@ const renderWithContext = (
|
||||
} as any
|
||||
}
|
||||
>
|
||||
<SpanScopeSelector queryName={queryName} />
|
||||
<SpanScopeSelector onChange={onChangeProp} query={queryProp} />
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
const selectOption = async (optionText: string): Promise<void> => {
|
||||
const selector = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selector);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Find the option by its content text and click it
|
||||
const option = await screen.findByText(optionText, {
|
||||
selector: '.ant-select-item-option-content',
|
||||
});
|
||||
fireEvent.click(option);
|
||||
};
|
||||
|
||||
describe('SpanScopeSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -82,13 +116,6 @@ describe('SpanScopeSelector', () => {
|
||||
});
|
||||
|
||||
describe('when selecting different options', () => {
|
||||
const selectOption = (optionText: string): void => {
|
||||
const selector = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selector);
|
||||
const option = screen.getByText(optionText);
|
||||
fireEvent.click(option);
|
||||
};
|
||||
|
||||
const assertFilterAdded = (
|
||||
updatedQuery: Query,
|
||||
expectedKey: string,
|
||||
@@ -106,13 +133,13 @@ describe('SpanScopeSelector', () => {
|
||||
);
|
||||
};
|
||||
|
||||
it('should remove span scope filters when selecting ALL_SPANS', () => {
|
||||
it('should remove span scope filters when selecting ALL_SPANS', async () => {
|
||||
const queryWithSpanScope = createQueryWithFilters([
|
||||
createSpanScopeFilter('isRoot'),
|
||||
]);
|
||||
renderWithContext('A', queryWithSpanScope);
|
||||
renderWithContext(queryWithSpanScope, undefined, defaultQueryBuilderQuery);
|
||||
|
||||
selectOption('All Spans');
|
||||
await selectOption('All Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
|
||||
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
|
||||
@@ -125,7 +152,8 @@ describe('SpanScopeSelector', () => {
|
||||
});
|
||||
|
||||
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
|
||||
renderWithContext();
|
||||
renderWithContext(defaultQuery, undefined, defaultQueryBuilderQuery);
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
await selectOption('Root Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
|
||||
@@ -135,9 +163,10 @@ describe('SpanScopeSelector', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should add isEntryPoint filter when selecting ENTRYPOINT_SPANS', () => {
|
||||
renderWithContext();
|
||||
selectOption('Entrypoint Spans');
|
||||
it('should add isEntryPoint filter when selecting ENTRYPOINT_SPANS', async () => {
|
||||
renderWithContext(defaultQuery, undefined, defaultQueryBuilderQuery);
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
await selectOption('Entrypoint Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
|
||||
assertFilterAdded(
|
||||
@@ -157,9 +186,180 @@ describe('SpanScopeSelector', () => {
|
||||
const queryWithFilter = createQueryWithFilters([
|
||||
createSpanScopeFilter(filterKey),
|
||||
]);
|
||||
renderWithContext('A', queryWithFilter);
|
||||
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
|
||||
expect(await screen.findByText(expectedText)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when onChange and query props are provided', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
const createLocalQuery = (
|
||||
filterItems: TagFilterItem[] = [],
|
||||
op: 'AND' | 'OR' = 'AND',
|
||||
): IBuilderQuery => ({
|
||||
...defaultQueryBuilderQuery,
|
||||
filters: { items: filterItems, op },
|
||||
});
|
||||
|
||||
const assertOnChangePayload = (
|
||||
callNumber: number, // To handle multiple calls if needed, usually 0 for single interaction
|
||||
expectedScopeKey: string | null,
|
||||
expectedNonScopeItems: TagFilterItem[] = [],
|
||||
): void => {
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
const onChangeArg = mockOnChange.mock.calls[callNumber][0] as TagFilter;
|
||||
const { items } = onChangeArg;
|
||||
|
||||
// Check for preservation of specific non-scope items
|
||||
expectedNonScopeItems.forEach((nonScopeItem) => {
|
||||
expect(items).toContainEqual(nonScopeItem);
|
||||
});
|
||||
|
||||
const scopeFiltersInPayload = items.filter(
|
||||
(filter) => filter.key?.type === 'spanSearchScope',
|
||||
);
|
||||
|
||||
if (expectedScopeKey) {
|
||||
expect(scopeFiltersInPayload.length).toBe(1);
|
||||
expect(scopeFiltersInPayload[0].key?.key).toBe(expectedScopeKey);
|
||||
expect(scopeFiltersInPayload[0].value).toBe('true');
|
||||
expect(scopeFiltersInPayload[0].op).toBe('=');
|
||||
} else {
|
||||
expect(scopeFiltersInPayload.length).toBe(0);
|
||||
}
|
||||
|
||||
const expectedTotalFilters =
|
||||
expectedNonScopeItems.length + (expectedScopeKey ? 1 : 0);
|
||||
expect(items.length).toBe(expectedTotalFilters);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
});
|
||||
|
||||
it('should initialize with ALL_SPANS if query prop has no scope filters', async () => {
|
||||
const localQuery = createLocalQuery();
|
||||
renderWithContext(defaultQuery, mockOnChange, localQuery);
|
||||
expect(await screen.findByText('All Spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should initialize with ROOT_SPANS if query prop has isRoot filter', async () => {
|
||||
const localQuery = createLocalQuery([createSpanScopeFilter('isRoot')]);
|
||||
renderWithContext(defaultQuery, mockOnChange, localQuery);
|
||||
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should initialize with ENTRYPOINT_SPANS if query prop has isEntryPoint filter', async () => {
|
||||
const localQuery = createLocalQuery([createSpanScopeFilter('isEntryPoint')]);
|
||||
renderWithContext(defaultQuery, mockOnChange, localQuery);
|
||||
expect(await screen.findByText('Entrypoint Spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange and not redirect when selecting ROOT_SPANS (from ALL_SPANS)', async () => {
|
||||
const localQuery = createLocalQuery(); // Initially All Spans
|
||||
const { container } = renderWithContext(
|
||||
defaultQuery,
|
||||
mockOnChange,
|
||||
localQuery,
|
||||
);
|
||||
expect(await screen.findByText('All Spans')).toBeInTheDocument();
|
||||
|
||||
await selectOption('Root Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
assertOnChangePayload(0, 'isRoot', []);
|
||||
expect(
|
||||
container.querySelector('span[title="Root Spans"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange with removed scope when selecting ALL_SPANS (from ROOT_SPANS)', async () => {
|
||||
const initialRootFilter = createSpanScopeFilter('isRoot');
|
||||
const localQuery = createLocalQuery([initialRootFilter]);
|
||||
const { container } = renderWithContext(
|
||||
defaultQuery,
|
||||
mockOnChange,
|
||||
localQuery,
|
||||
);
|
||||
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
|
||||
|
||||
await selectOption('All Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
assertOnChangePayload(0, null, []);
|
||||
|
||||
expect(
|
||||
container.querySelector('span[title="All Spans"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange, replacing isRoot with isEntryPoint', async () => {
|
||||
const initialRootFilter = createSpanScopeFilter('isRoot');
|
||||
const localQuery = createLocalQuery([initialRootFilter]);
|
||||
const { container } = renderWithContext(
|
||||
defaultQuery,
|
||||
mockOnChange,
|
||||
localQuery,
|
||||
);
|
||||
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
|
||||
|
||||
await selectOption('Entrypoint Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
assertOnChangePayload(0, 'isEntryPoint', []);
|
||||
expect(
|
||||
container.querySelector('span[title="Entrypoint Spans"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should preserve non-scope filters from query prop when changing scope', async () => {
|
||||
const nonScopeItem = createNonScopeFilter('customTag', 'customValue');
|
||||
const initialRootFilter = createSpanScopeFilter('isRoot');
|
||||
const localQuery = createLocalQuery([nonScopeItem, initialRootFilter], 'OR');
|
||||
|
||||
const { container } = renderWithContext(
|
||||
defaultQuery,
|
||||
mockOnChange,
|
||||
localQuery,
|
||||
);
|
||||
expect(await screen.findByText('Root Spans')).toBeInTheDocument();
|
||||
|
||||
await selectOption('Entrypoint Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
assertOnChangePayload(0, 'isEntryPoint', [nonScopeItem]);
|
||||
expect(
|
||||
container.querySelector('span[title="Entrypoint Spans"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should preserve non-scope filters when changing to ALL_SPANS', async () => {
|
||||
const nonScopeItem1 = createNonScopeFilter('service', 'checkout');
|
||||
const nonScopeItem2 = createNonScopeFilter('version', 'v1');
|
||||
const initialEntryFilter = createSpanScopeFilter('isEntryPoint');
|
||||
const localQuery = createLocalQuery([
|
||||
nonScopeItem1,
|
||||
initialEntryFilter,
|
||||
nonScopeItem2,
|
||||
]);
|
||||
|
||||
const { container } = renderWithContext(
|
||||
defaultQuery,
|
||||
mockOnChange,
|
||||
localQuery,
|
||||
);
|
||||
expect(await screen.findByText('Entrypoint Spans')).toBeInTheDocument();
|
||||
|
||||
await selectOption('All Spans');
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
assertOnChangePayload(0, null, [nonScopeItem1, nonScopeItem2]);
|
||||
expect(
|
||||
container.querySelector('span[title="All Spans"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,14 +30,15 @@ export const getChartData = (
|
||||
};
|
||||
const chartLabels: ChartData<'line'>['labels'] = [];
|
||||
|
||||
Object.keys(allDataPoints ?? {}).forEach((timestamp) => {
|
||||
const key = allDataPoints[timestamp];
|
||||
if (key.value) {
|
||||
chartDataset.data.push(key.value);
|
||||
const date = dayjs(key.timestamp / 1000000);
|
||||
chartLabels.push(date.toDate().getTime());
|
||||
}
|
||||
});
|
||||
if (allDataPoints && typeof allDataPoints === 'object')
|
||||
Object.keys(allDataPoints).forEach((timestamp) => {
|
||||
const key = allDataPoints[timestamp];
|
||||
if (key.value) {
|
||||
chartDataset.data.push(key.value);
|
||||
const date = dayjs(key.timestamp / 1000000);
|
||||
chartLabels.push(date.toDate().getTime());
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
datasets: [
|
||||
|
||||
@@ -42,7 +42,19 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
|
||||
<Button className="previous-btn">
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
onClick={(): void => history.push(ROUTES.TRACES_EXPLORER)}
|
||||
onClick={(): void => {
|
||||
// Check if page was opened in new tab (no referrer from same origin)
|
||||
// or if there's no meaningful history to go back to
|
||||
const hasValidReferrer =
|
||||
document.referrer &&
|
||||
new URL(document.referrer).origin === window.location.origin;
|
||||
|
||||
if (hasValidReferrer && window.history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
|
||||
@@ -16,11 +16,51 @@
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
&--details {
|
||||
.ant-modal-content {
|
||||
height: 710px;
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
background: var(--bg-ink-400);
|
||||
border-top: 1px solid var(--bg-slate-500);
|
||||
padding: 16px !important;
|
||||
.add-span-to-funnel-modal {
|
||||
&__save-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
width: 135px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--bg-vanilla-400);
|
||||
.ant-btn-icon {
|
||||
svg {
|
||||
stroke: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__discard-button {
|
||||
background: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { ArrowLeft, Plus, Search } from 'lucide-react';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ArrowLeft, Check, Plus, Search } from 'lucide-react';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
|
||||
import {
|
||||
FunnelProvider,
|
||||
useFunnelContext,
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
@@ -28,10 +32,35 @@ enum ModalView {
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
onChangesDetected,
|
||||
triggerDiscard,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
triggerAutoSave: boolean;
|
||||
showNotifications: boolean;
|
||||
onChangesDetected: (hasChanges: boolean) => void;
|
||||
triggerDiscard: boolean;
|
||||
}): JSX.Element {
|
||||
const { handleRestoreSteps, steps } = useFunnelContext();
|
||||
|
||||
// Track changes between current steps and original steps
|
||||
useEffect(() => {
|
||||
const hasChanges = !isEqual(steps, funnel.steps);
|
||||
if (onChangesDetected) {
|
||||
onChangesDetected(hasChanges);
|
||||
}
|
||||
}, [steps, funnel.steps, onChangesDetected]);
|
||||
|
||||
// Handle discard when triggered from parent
|
||||
useEffect(() => {
|
||||
if (triggerDiscard && funnel.steps) {
|
||||
handleRestoreSteps(funnel.steps);
|
||||
}
|
||||
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
|
||||
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
@@ -39,10 +68,18 @@ function FunnelDetailsView({
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
isSpanDetailsPage
|
||||
/>
|
||||
<FunnelConfiguration funnel={funnel} isTraceDetailsPage span={span} />
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
disableAutoSave
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -60,6 +97,9 @@ function AddSpanToFunnelModal({
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
const [triggerSave, setTriggerSave] = useState<boolean>(false);
|
||||
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
|
||||
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -92,12 +132,26 @@ function AddSpanToFunnelModal({
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
setIsUnsavedChanges(false);
|
||||
setTriggerSave(false);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveFunnel = (): void => {
|
||||
setTriggerSave(true);
|
||||
// Reset trigger after a brief moment to allow the save to be processed
|
||||
setTimeout(() => setTriggerSave(false), 100);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setTriggerDiscard(true);
|
||||
// Reset trigger after a brief moment
|
||||
setTimeout(() => setTriggerDiscard(false), 100);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
@@ -156,7 +210,14 @@ function AddSpanToFunnelModal({
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider funnelId={selectedFunnelId}>
|
||||
<FunnelDetailsView funnel={funnelDetails.payload} span={span} />
|
||||
<FunnelDetailsView
|
||||
funnel={funnelDetails.payload}
|
||||
span={span}
|
||||
triggerAutoSave={triggerSave}
|
||||
showNotifications
|
||||
onChangesDetected={setIsUnsavedChanges}
|
||||
triggerDiscard={triggerDiscard}
|
||||
/>
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</div>
|
||||
@@ -175,18 +236,43 @@ function AddSpanToFunnelModal({
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
okText="Save Funnel"
|
||||
footer={
|
||||
activeView === ModalView.LIST && !!filteredData?.length ? (
|
||||
<Button
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>
|
||||
) : null
|
||||
activeView === ModalView.DETAILS
|
||||
? [
|
||||
<Button key="close" onClick={onClose}>
|
||||
Close
|
||||
</Button>,
|
||||
<Button
|
||||
type="default"
|
||||
key="discard"
|
||||
onClick={handleDiscard}
|
||||
className="add-span-to-funnel-modal__discard-button"
|
||||
disabled={!isUnsavedChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
className="add-span-to-funnel-modal__save-button"
|
||||
onClick={handleSaveFunnel}
|
||||
disabled={!isUnsavedChanges}
|
||||
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
|
||||
>
|
||||
Save Funnel
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="create"
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
|
||||
@@ -136,8 +136,12 @@ function Filters({
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={BASE_FILTER_QUERY}
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
|
||||
@@ -36,8 +36,14 @@ const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function useFunnelConfiguration({
|
||||
funnel,
|
||||
disableAutoSave = false,
|
||||
triggerAutoSave = false,
|
||||
showNotifications = false,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
disableAutoSave?: boolean;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}): UseFunnelConfiguration {
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -45,6 +51,7 @@ export default function useFunnelConfiguration({
|
||||
initialSteps,
|
||||
hasIncompleteStepFields,
|
||||
handleRestoreSteps,
|
||||
handleRunFunnel,
|
||||
} = useFunnelContext();
|
||||
|
||||
// State management
|
||||
@@ -82,13 +89,23 @@ export default function useFunnelConfiguration({
|
||||
step.service_name !== nextStep.service_name ||
|
||||
step.span_name !== nextStep.span_name ||
|
||||
!isEqual(step.filters, nextStep.filters) ||
|
||||
step.has_errors !== nextStep.has_errors
|
||||
step.has_errors !== nextStep.has_errors ||
|
||||
step.latency_pointer !== nextStep.latency_pointer
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasFunnelLatencyTypeChanged = useCallback(
|
||||
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean =>
|
||||
prevSteps.some((step, index) => {
|
||||
const nextStep = nextSteps[index];
|
||||
return step.latency_type !== nextStep.latency_type;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Mutation payload preparation
|
||||
const getUpdatePayload = useCallback(
|
||||
() => ({
|
||||
@@ -106,8 +123,20 @@ export default function useFunnelConfiguration({
|
||||
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.funnel_id, selectedTime],
|
||||
[funnel.funnel_id, selectedTime],
|
||||
);
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect(() => {
|
||||
if (hasStepsChanged() && !hasIncompleteStepFields) {
|
||||
// Determine if we should save based on the mode
|
||||
let shouldSave = false;
|
||||
|
||||
if (disableAutoSave) {
|
||||
// Manual save mode: only save when explicitly triggered
|
||||
shouldSave = triggerAutoSave;
|
||||
} else {
|
||||
// Auto-save mode: save when steps have changed and no incomplete fields
|
||||
shouldSave = hasStepsChanged() && !hasIncompleteStepFields;
|
||||
}
|
||||
|
||||
if (shouldSave && !isEqual(debouncedSteps, lastValidatedSteps)) {
|
||||
updateStepsMutation.mutate(getUpdatePayload(), {
|
||||
onSuccess: (data) => {
|
||||
const updatedFunnelSteps = data?.payload?.steps;
|
||||
@@ -116,13 +145,16 @@ export default function useFunnelConfiguration({
|
||||
|
||||
queryClient.setQueryData(
|
||||
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.funnel_id],
|
||||
(oldData: any) => ({
|
||||
...oldData,
|
||||
payload: {
|
||||
...oldData.payload,
|
||||
steps: updatedFunnelSteps,
|
||||
},
|
||||
}),
|
||||
(oldData: any) => {
|
||||
if (!oldData?.payload) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
payload: {
|
||||
...oldData.payload,
|
||||
steps: updatedFunnelSteps,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
lastSavedStepsStateRef.current = updatedFunnelSteps;
|
||||
@@ -131,17 +163,29 @@ export default function useFunnelConfiguration({
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
);
|
||||
|
||||
// Only validate if service_name or span_name changed
|
||||
if (
|
||||
if (hasFunnelLatencyTypeChanged(lastValidatedSteps, debouncedSteps)) {
|
||||
handleRunFunnel();
|
||||
setLastValidatedSteps(debouncedSteps);
|
||||
}
|
||||
// Only validate if funnel steps definitions
|
||||
else if (
|
||||
!hasIncompleteStepFields &&
|
||||
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
|
||||
) {
|
||||
queryClient.refetchQueries(validateStepsQueryKey);
|
||||
setLastValidatedSteps(debouncedSteps);
|
||||
}
|
||||
|
||||
// Show success notification only when requested
|
||||
if (showNotifications) {
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: 'Funnel configuration updated successfully',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
onError: (error: any) => {
|
||||
handleRestoreSteps(lastSavedStepsStateRef.current);
|
||||
queryClient.setQueryData(
|
||||
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.funnel_id],
|
||||
@@ -153,6 +197,16 @@ export default function useFunnelConfiguration({
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Show error notification only when requested
|
||||
if (showNotifications) {
|
||||
notifications.error({
|
||||
message: 'Failed to update funnel',
|
||||
description:
|
||||
error?.message ||
|
||||
'An error occurred while updating the funnel configuration',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -165,6 +219,9 @@ export default function useFunnelConfiguration({
|
||||
lastValidatedSteps,
|
||||
queryClient,
|
||||
validateStepsQueryKey,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
disableAutoSave,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,11 +49,7 @@ export function useFunnelMetrics({
|
||||
},
|
||||
{
|
||||
title: `P99 Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
(sourceData.latency ?? sourceData.p99_latency).toString(),
|
||||
'ms',
|
||||
),
|
||||
value: getYAxisFormattedValue(sourceData.latency.toString(), 'ms'),
|
||||
},
|
||||
];
|
||||
}, [overviewData?.payload?.data]);
|
||||
@@ -95,7 +91,10 @@ export function useFunnelStepsMetrics({
|
||||
} = useFunnelStepsOverview(funnelId, payload);
|
||||
|
||||
const latencyType = useMemo(
|
||||
() => (stepStart ? steps[stepStart]?.latency_type : LatencyOptions.P99),
|
||||
() =>
|
||||
stepStart
|
||||
? steps[stepStart]?.latency_type ?? LatencyOptions.P99
|
||||
: LatencyOptions.P99,
|
||||
[stepStart, steps],
|
||||
);
|
||||
|
||||
@@ -117,10 +116,9 @@ export function useFunnelStepsMetrics({
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `${latencyType?.toUpperCase()} Latency`,
|
||||
title: `${latencyType.toUpperCase()} Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
((sourceData.latency ?? sourceData.p99_latency) * 1_000_000).toString(),
|
||||
(sourceData.latency * 1_000_000).toString(),
|
||||
'ns',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { useQueryBuilder } from '../useQueryBuilder';
|
||||
import { useQueryOperations } from '../useQueryBuilderOperations';
|
||||
|
||||
// Mock the useQueryBuilder hook
|
||||
jest.mock('../useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
const mockHandleSetFormulaData = jest.fn();
|
||||
const mockRemoveQueryBuilderEntityByIndex = jest.fn();
|
||||
const mockSetLastUsedQuery = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
const defaultMockQuery: IBuilderQuery = {
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateOperator: MetricAggregateOperator.AVG,
|
||||
aggregateAttribute: {
|
||||
key: 'test_metric',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.GAUGE,
|
||||
} as BaseAutocompleteData,
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: '',
|
||||
having: [],
|
||||
limit: null,
|
||||
queryName: 'test_query',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
stepInterval: 60,
|
||||
expression: '',
|
||||
disabled: false,
|
||||
reduceTo: 'avg',
|
||||
legend: '',
|
||||
};
|
||||
|
||||
const setupMockQueryBuilder = (): void => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
handleSetFormulaData: mockHandleSetFormulaData,
|
||||
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
|
||||
setLastUsedQuery: mockSetLastUsedQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
panelType: 'time_series',
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [defaultMockQuery, defaultMockQuery],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderHookWithProps = (
|
||||
props = {},
|
||||
): { current: ReturnType<typeof useQueryOperations> } => {
|
||||
const { result } = renderHook(() =>
|
||||
useQueryOperations({
|
||||
query: defaultMockQuery,
|
||||
index: 0,
|
||||
entityVersion: ENTITY_VERSION_V4,
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setupMockQueryBuilder();
|
||||
});
|
||||
|
||||
describe('handleChangeAggregatorAttribute', () => {
|
||||
it('should set AVG operators when type is empty but key is present - unkown metric', () => {
|
||||
const result = renderHookWithProps();
|
||||
const newAttribute: BaseAutocompleteData = {
|
||||
key: 'new_metric',
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregateAttribute: newAttribute,
|
||||
aggregateOperator: MetricAggregateOperator.AVG,
|
||||
timeAggregation: MetricAggregateOperator.AVG,
|
||||
spaceAggregation: MetricAggregateOperator.AVG,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set COUNT/RATE/SUM operators when both type and key are empty', () => {
|
||||
const result = renderHookWithProps();
|
||||
const newAttribute: BaseAutocompleteData = {
|
||||
key: '',
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(newAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregateAttribute: newAttribute,
|
||||
aggregateOperator: MetricAggregateOperator.COUNT,
|
||||
timeAggregation: MetricAggregateOperator.RATE,
|
||||
spaceAggregation: MetricAggregateOperator.SUM,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
metricsGaugeSpaceAggregateOperatorOptions,
|
||||
metricsHistogramSpaceAggregateOperatorOptions,
|
||||
metricsSumSpaceAggregateOperatorOptions,
|
||||
metricsUnknownSpaceAggregateOperatorOptions,
|
||||
metricsUnknownTimeAggregateOperatorOptions,
|
||||
} from 'constants/queryBuilderOperators';
|
||||
import {
|
||||
listViewInitialLogQuery,
|
||||
@@ -21,6 +23,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getMetricsOperatorsByAttributeType } from 'lib/newQueryBuilder/getMetricsOperatorsByAttributeType';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
import { findDataTypeOfOperator } from 'lib/query/findDataTypeOfOperator';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
@@ -145,12 +148,18 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
|
||||
const handleMetricAggregateAtributeTypes = useCallback(
|
||||
(aggregateAttribute: BaseAutocompleteData): any => {
|
||||
const newOperators = getMetricsOperatorsByAttributeType({
|
||||
dataSource: DataSource.METRICS,
|
||||
panelType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
aggregateAttributeType:
|
||||
(aggregateAttribute.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
// operators for unknown metric
|
||||
const isUnknownMetric =
|
||||
isEmpty(aggregateAttribute.type) && !isEmpty(aggregateAttribute.key);
|
||||
|
||||
const newOperators = isUnknownMetric
|
||||
? metricsUnknownTimeAggregateOperatorOptions
|
||||
: getMetricsOperatorsByAttributeType({
|
||||
dataSource: DataSource.METRICS,
|
||||
panelType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
aggregateAttributeType:
|
||||
(aggregateAttribute.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
|
||||
switch (aggregateAttribute.type) {
|
||||
case ATTRIBUTE_TYPES.SUM:
|
||||
@@ -168,7 +177,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
break;
|
||||
default:
|
||||
setSpaceAggregationOptions(metricsGaugeSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -202,6 +211,21 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
}
|
||||
|
||||
newQuery.spaceAggregation = '';
|
||||
|
||||
// Handled query with unknown metric to avoid 400 and 500 errors
|
||||
// With metric value typed and not available then - time - 'avg', space - 'avg'
|
||||
// If not typed - time - 'rate', space - 'sum', op - 'count'
|
||||
if (isEmpty(newQuery.aggregateAttribute.type)) {
|
||||
if (!isEmpty(newQuery.aggregateAttribute.key)) {
|
||||
newQuery.aggregateOperator = MetricAggregateOperator.AVG;
|
||||
newQuery.timeAggregation = MetricAggregateOperator.AVG;
|
||||
newQuery.spaceAggregation = MetricAggregateOperator.AVG;
|
||||
} else {
|
||||
newQuery.aggregateOperator = MetricAggregateOperator.COUNT;
|
||||
newQuery.timeAggregation = MetricAggregateOperator.RATE;
|
||||
newQuery.spaceAggregation = MetricAggregateOperator.SUM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSetQueryData(index, newQuery);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A React hook for interacting with localStorage.
|
||||
@@ -6,35 +6,47 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
*
|
||||
* @template T The type of the value to be stored.
|
||||
* @param {string} key The localStorage key.
|
||||
* @param {T | (() => T)} initialValue The initial value to use if no value is found in localStorage,
|
||||
* @param {T | (() => T)} defaultValue The default value to use if no value is found in localStorage,
|
||||
* @returns {[T, (value: T | ((prevState: T) => T)) => void, () => void]}
|
||||
* A tuple containing:
|
||||
* - The current value from state (and localStorage).
|
||||
* - A function to set the value (updates state and localStorage).
|
||||
* - A function to remove the value from localStorage and reset state to initialValue.
|
||||
* - A function to remove the value from localStorage and reset state to defaultValue.
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | (() => T),
|
||||
defaultValue: T | (() => T),
|
||||
): [T, (value: T | ((prevState: T) => T)) => void, () => void] {
|
||||
// This function resolves the initialValue if it's a function,
|
||||
// Stabilize the defaultValue to prevent unnecessary re-renders
|
||||
const defaultValueRef = useRef<T | (() => T)>(defaultValue);
|
||||
|
||||
// Update the ref if defaultValue changes (for cases where it's intentionally dynamic)
|
||||
useEffect(() => {
|
||||
if (defaultValueRef.current !== defaultValue) {
|
||||
defaultValueRef.current = defaultValue;
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
// This function resolves the defaultValue if it's a function,
|
||||
// and handles potential errors during localStorage access or JSON parsing.
|
||||
const readValueFromStorage = useCallback((): T => {
|
||||
const resolvedInitialValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
const resolveddefaultValue =
|
||||
defaultValueRef.current instanceof Function
|
||||
? (defaultValueRef.current as () => T)()
|
||||
: defaultValueRef.current;
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
// If item exists, parse it, otherwise return the resolved initial value.
|
||||
// If item exists, parse it, otherwise return the resolved default value.
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error and fall back to initial value if reading/parsing fails.
|
||||
// Log error and fall back to default value if reading/parsing fails.
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
}
|
||||
return resolvedInitialValue;
|
||||
}, [key, initialValue]);
|
||||
return resolveddefaultValue;
|
||||
}, [key]);
|
||||
|
||||
// Initialize state by reading from localStorage.
|
||||
const [storedValue, setStoredValue] = useState<T>(readValueFromStorage);
|
||||
@@ -63,17 +75,19 @@ export function useLocalStorage<T>(
|
||||
const removeValue = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
// Reset state to the (potentially resolved) initialValue.
|
||||
// Reset state to the (potentially resolved) defaultValue.
|
||||
setStoredValue(
|
||||
initialValue instanceof Function ? initialValue() : initialValue,
|
||||
defaultValueRef.current instanceof Function
|
||||
? (defaultValueRef.current as () => T)()
|
||||
: defaultValueRef.current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
}, [key]);
|
||||
|
||||
// useEffect to update the storedValue if the key changes,
|
||||
// or if the initialValue prop changes causing readValueFromStorage to change.
|
||||
// or if the defaultValue prop changes causing readValueFromStorage to change.
|
||||
// This ensures the hook reflects the correct localStorage item if its key prop dynamically changes.
|
||||
useEffect(() => {
|
||||
setStoredValue(readValueFromStorage());
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
metricsOperatorsByType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { metricsEmptyTimeAggregateOperatorOptions } from 'constants/queryBuilderOperators';
|
||||
import {
|
||||
metricsEmptyTimeAggregateOperatorOptions,
|
||||
metricsUnknownTimeAggregateOperatorOptions,
|
||||
} from 'constants/queryBuilderOperators';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
@@ -27,5 +31,9 @@ export const getMetricsOperatorsByAttributeType = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.METRICS && isEmpty(aggregateAttributeType)) {
|
||||
return metricsUnknownTimeAggregateOperatorOptions;
|
||||
}
|
||||
|
||||
return metricsEmptyTimeAggregateOperatorOptions;
|
||||
};
|
||||
|
||||
@@ -6,11 +6,7 @@ import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
import NotFound from 'components/NotFound';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -74,9 +70,6 @@ BreadCrumbItem.defaultProps = {
|
||||
function AlertDetails(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { routes } = useRouteTabUtils();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -92,27 +85,6 @@ function AlertDetails(): JSX.Element {
|
||||
document.title = alertTitle || document.title;
|
||||
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (alertDetailsResponse?.payload?.data?.id) {
|
||||
const ruleUUID = alertDetailsResponse.payload.data.id;
|
||||
if (ruleId !== ruleUUID) {
|
||||
urlQuery.set(QueryParams.ruleId, ruleUUID);
|
||||
const generatedUrl = `${window.location.pathname}?${urlQuery}`;
|
||||
notifications.info({
|
||||
message:
|
||||
"We're transitioning alert rule IDs from integers to UUIDs.Both old and new alert links will continue to work after this change - existing notifications using integer IDs will remain functional while new alerts will use the UUID format. Please use the updated link in the URL for future references",
|
||||
});
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
alertDetailsResponse?.payload?.data.id,
|
||||
notifications,
|
||||
ruleId,
|
||||
safeNavigate,
|
||||
urlQuery,
|
||||
]);
|
||||
|
||||
if (
|
||||
isError ||
|
||||
!isValidRuleId ||
|
||||
|
||||
@@ -4,6 +4,7 @@ import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { EventSourceProvider } from 'providers/EventSource';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useEffect } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -17,7 +18,9 @@ function LiveLogs(): JSX.Element {
|
||||
|
||||
return (
|
||||
<EventSourceProvider>
|
||||
<LiveLogsContainer />
|
||||
<PreferenceContextProvider>
|
||||
<LiveLogsContainer />
|
||||
</PreferenceContextProvider>
|
||||
</EventSourceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { noop } from 'lodash-es';
|
||||
import { logsQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
// https://virtuoso.dev/mocking-in-tests/
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
@@ -73,6 +74,25 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock usePreferenceSync
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const logsQueryServerRequest = (): void =>
|
||||
server.use(
|
||||
rest.post(queryRangeURL, (req, res, ctx) =>
|
||||
@@ -88,7 +108,11 @@ describe('Logs Explorer Tests', () => {
|
||||
queryByText,
|
||||
getByTestId,
|
||||
queryByTestId,
|
||||
} = render(<LogsExplorer />);
|
||||
} = render(
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// check the presence of frequency chart content
|
||||
expect(getByText(frequencyChartContent)).toBeInTheDocument();
|
||||
@@ -124,11 +148,13 @@ describe('Logs Explorer Tests', () => {
|
||||
// mocking the query range API to return the logs
|
||||
logsQueryServerRequest();
|
||||
const { queryByText, queryByTestId } = render(
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
<PreferenceContextProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// check for loading state to be not present
|
||||
@@ -192,11 +218,13 @@ describe('Logs Explorer Tests', () => {
|
||||
isStagedQueryUpdated: (): boolean => false,
|
||||
}}
|
||||
>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
<PreferenceContextProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 300, itemHeight: 100 }}
|
||||
>
|
||||
<LogsExplorer />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -213,7 +241,11 @@ describe('Logs Explorer Tests', () => {
|
||||
});
|
||||
|
||||
test('frequency chart visibility and switch toggle', async () => {
|
||||
const { getByRole, queryByText } = render(<LogsExplorer />);
|
||||
const { getByRole, queryByText } = render(
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>,
|
||||
);
|
||||
|
||||
// check the presence of Frequency Chart
|
||||
expect(queryByText('Frequency chart')).toBeInTheDocument();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { isEqual, isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -35,6 +36,8 @@ function LogsExplorer(): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<SELECTED_VIEWS>(
|
||||
SELECTED_VIEWS.SEARCH,
|
||||
);
|
||||
const { preferences, loading: preferencesLoading } = usePreferenceContext();
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS,
|
||||
@@ -83,7 +86,6 @@ function LogsExplorer(): JSX.Element {
|
||||
}, [currentQuery.builder.queryData, currentQuery.builder.queryData.length]);
|
||||
|
||||
const {
|
||||
queryData: optionsQueryData,
|
||||
redirectWithQuery: redirectWithOptionsData,
|
||||
} = useUrlQueryData<OptionsQuery>(URL_OPTIONS, defaultOptionsQuery);
|
||||
|
||||
@@ -164,12 +166,34 @@ function LogsExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const migratedQuery = migrateOptionsQuery(optionsQueryData);
|
||||
if (!preferences || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
const migratedQuery = migrateOptionsQuery({
|
||||
selectColumns: preferences.columns || defaultLogsSelectedColumns,
|
||||
maxLines: preferences.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
format: preferences.formatting?.format || defaultOptionsQuery.format,
|
||||
fontSize: preferences.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
version: preferences.formatting?.version,
|
||||
});
|
||||
// Only redirect if the query was actually modified
|
||||
if (!isEqual(migratedQuery, optionsQueryData)) {
|
||||
if (
|
||||
!isEqual(migratedQuery, {
|
||||
selectColumns: preferences?.columns,
|
||||
maxLines: preferences?.formatting?.maxLines,
|
||||
format: preferences?.formatting?.format,
|
||||
fontSize: preferences?.formatting?.fontSize,
|
||||
version: preferences?.formatting?.version,
|
||||
})
|
||||
) {
|
||||
redirectWithOptionsData(migratedQuery);
|
||||
}
|
||||
}, [migrateOptionsQuery, optionsQueryData, redirectWithOptionsData]);
|
||||
}, [
|
||||
migrateOptionsQuery,
|
||||
preferences,
|
||||
redirectWithOptionsData,
|
||||
preferencesLoading,
|
||||
]);
|
||||
|
||||
const isMultipleQueries = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -4,9 +4,14 @@ import { Compass, TowerControl, Workflow } from 'lucide-react';
|
||||
import LogsExplorer from 'pages/LogsExplorer';
|
||||
import Pipelines from 'pages/Pipelines';
|
||||
import SaveView from 'pages/SaveView';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const logsExplorer: TabRoutes = {
|
||||
Component: LogsExplorer,
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
|
||||
@@ -2,8 +2,13 @@ import './MetricsExplorerPage.styles.scss';
|
||||
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import history from 'lib/history';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-use';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { Explorer, Summary, Views } from './constants';
|
||||
|
||||
@@ -12,6 +17,20 @@ function MetricsExplorerPage(): JSX.Element {
|
||||
|
||||
const routes: TabRoutes[] = [Summary, Explorer, Views];
|
||||
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.METRICS,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useShareBuilderUrl(defaultQuery);
|
||||
|
||||
return (
|
||||
<div className="metrics-explorer-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import ExplorerPage from 'container/MetricsExplorer/Explorer';
|
||||
import SummaryPage from 'container/MetricsExplorer/Summary';
|
||||
import { BarChart2, Compass, TowerControl } from 'lucide-react';
|
||||
import SaveView from 'pages/SaveView';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
export const Summary: TabRoutes = {
|
||||
Component: SummaryPage,
|
||||
@@ -17,7 +18,11 @@ export const Summary: TabRoutes = {
|
||||
};
|
||||
|
||||
export const Explorer: TabRoutes = {
|
||||
Component: ExplorerPage,
|
||||
Component: (): JSX.Element => (
|
||||
<PreferenceContextProvider>
|
||||
<ExplorerPage />
|
||||
</PreferenceContextProvider>
|
||||
),
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<Compass size={16} /> Explorer
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
} from 'components/ExplorerCard/utils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import {
|
||||
MetricsExplorerEventKeys,
|
||||
MetricsExplorerEvents,
|
||||
} from 'container/MetricsExplorer/events';
|
||||
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useUpdateView } from 'hooks/saveViews/useUpdateView';
|
||||
@@ -155,6 +159,10 @@ function SaveView(): JSX.Element {
|
||||
logEvent('Logs Views: Views visited', {
|
||||
number: viewsData?.data?.data?.length,
|
||||
});
|
||||
} else if (sourcepage === DataSource.METRICS) {
|
||||
logEvent(MetricsExplorerEvents.TabChanged, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'views',
|
||||
});
|
||||
}
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
@@ -176,6 +184,9 @@ function SaveView(): JSX.Element {
|
||||
});
|
||||
hideEditViewModal();
|
||||
refetchAllView();
|
||||
logEvent(MetricsExplorerEvents.ViewEdited, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'views',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification(notifications, err);
|
||||
@@ -204,6 +215,10 @@ function SaveView(): JSX.Element {
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[sourcepage],
|
||||
);
|
||||
logEvent(MetricsExplorerEvents.OpenInExplorerClicked, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'views',
|
||||
[MetricsExplorerEventKeys.ViewName]: name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { MenuItemLabelGeneratorProps } from 'components/ExplorerCard/types';
|
||||
import { showErrorNotification } from 'components/ExplorerCard/utils';
|
||||
import {
|
||||
MetricsExplorerEventKeys,
|
||||
MetricsExplorerEvents,
|
||||
} from 'container/MetricsExplorer/events';
|
||||
import { UseMutateAsyncFunction } from 'react-query';
|
||||
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||
|
||||
@@ -29,6 +34,9 @@ export const deleteViewHandler = ({
|
||||
message: 'View Deleted Successfully',
|
||||
});
|
||||
refetchAllView();
|
||||
logEvent(MetricsExplorerEvents.ViewDeleted, {
|
||||
[MetricsExplorerEventKeys.Tab]: 'views',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification(notifications, err);
|
||||
|
||||
@@ -75,7 +75,7 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
const isGroupByExist = useMemo(() => {
|
||||
const groupByCount: number = currentQuery.builder.queryData.reduce<number>(
|
||||
(acc, query) => acc + query.groupBy.length,
|
||||
(acc, query) => acc + (query?.groupBy?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ function DeleteFunnelStep({
|
||||
onCancel={onClose}
|
||||
rootClassName="funnel-modal delete-funnel-modal"
|
||||
cancelText="Cancel"
|
||||
okText="Delete Funnel"
|
||||
okText="Delete Step"
|
||||
okButtonProps={{
|
||||
icon: <Trash2 size={14} />,
|
||||
type: 'primary',
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
&__steps-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
@@ -38,13 +43,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
&__description-wrapper {
|
||||
padding: 16px 16px 0 16px;
|
||||
.funnel {
|
||||
&-title {
|
||||
color: var(--bg-vanilla-500);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
}
|
||||
&-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
padding: 16px 16px 0 16px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.funnel-item__action-icon {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import './FunnelConfiguration.styles.scss';
|
||||
|
||||
import { Button, Divider, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
|
||||
import { PencilLine } from 'lucide-react';
|
||||
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
|
||||
@@ -19,15 +21,24 @@ interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
disableAutoSave?: boolean;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}
|
||||
|
||||
function FunnelConfiguration({
|
||||
funnel,
|
||||
isTraceDetailsPage,
|
||||
span,
|
||||
disableAutoSave,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
}: FunnelConfigurationProps): JSX.Element {
|
||||
const { isPopoverOpen, setIsPopoverOpen, steps } = useFunnelConfiguration({
|
||||
funnel,
|
||||
disableAutoSave,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
});
|
||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState<boolean>(
|
||||
false,
|
||||
@@ -40,55 +51,69 @@ function FunnelConfiguration({
|
||||
return (
|
||||
<div className="funnel-configuration">
|
||||
{!isTraceDetailsPage && (
|
||||
<>
|
||||
<div className="funnel-configuration__header">
|
||||
<div className="funnel-configuration__header-left">
|
||||
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
|
||||
</div>
|
||||
<div className="funnel-configuration__header-right">
|
||||
<Tooltip
|
||||
title={
|
||||
funnel?.description
|
||||
? 'Edit funnel description'
|
||||
: 'Add funnel description'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="funnel-item__action-btn funnel-configuration__rename-btn"
|
||||
icon={<PencilLine size={14} />}
|
||||
onClick={(): void => setIsDescriptionModalOpen(true)}
|
||||
aria-label="Edit Funnel Description"
|
||||
/>
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
<Divider type="vertical" />
|
||||
<FunnelItemPopover
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
funnel={funnel}
|
||||
<div className="funnel-configuration__header">
|
||||
<div className="funnel-configuration__header-left">
|
||||
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
|
||||
</div>
|
||||
<div className="funnel-configuration__header-right">
|
||||
<Tooltip
|
||||
title={
|
||||
funnel?.description
|
||||
? 'Edit funnel description'
|
||||
: 'Add funnel description'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="funnel-item__action-btn funnel-configuration__rename-btn"
|
||||
icon={<PencilLine size={14} />}
|
||||
onClick={(): void => setIsDescriptionModalOpen(true)}
|
||||
aria-label="Edit Funnel Description"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
<Divider type="vertical" />
|
||||
<FunnelItemPopover
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
funnel={funnel}
|
||||
/>
|
||||
</div>
|
||||
<div className="funnel-configuration__description">
|
||||
{funnel?.description}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="funnel-configuration__steps-wrapper">
|
||||
<div className="funnel-configuration__steps">
|
||||
{!isTraceDetailsPage && <StepsHeader />}
|
||||
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
|
||||
</div>
|
||||
{!isTraceDetailsPage && <StepsFooter stepsCount={steps.length} />}
|
||||
)}
|
||||
<div
|
||||
className={cx('funnel-configuration__steps-wrapper', {
|
||||
'funnel-details-page': !isTraceDetailsPage,
|
||||
})}
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
{!isTraceDetailsPage && (
|
||||
<div className="funnel-configuration__description-wrapper">
|
||||
<div className="funnel-title">{funnel.funnel_name}</div>
|
||||
<div className="funnel-description">
|
||||
{funnel?.description ?? 'No description added.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="funnel-configuration__steps">
|
||||
{!isTraceDetailsPage && <StepsHeader />}
|
||||
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
|
||||
</div>
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
|
||||
{!isTraceDetailsPage && (
|
||||
<AddFunnelDescriptionModal
|
||||
isOpen={isDescriptionModalOpen}
|
||||
onClose={handleDescriptionModalClose}
|
||||
funnelId={funnel.funnel_id}
|
||||
funnelDescription={funnel?.description || ''}
|
||||
/>
|
||||
<>
|
||||
<StepsFooter stepsCount={steps.length} />
|
||||
<AddFunnelDescriptionModal
|
||||
isOpen={isDescriptionModalOpen}
|
||||
onClose={handleDescriptionModalClose}
|
||||
funnelId={funnel.funnel_id}
|
||||
funnelDescription={funnel?.description || ''}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -97,6 +122,9 @@ function FunnelConfiguration({
|
||||
FunnelConfiguration.defaultProps = {
|
||||
isTraceDetailsPage: false,
|
||||
span: undefined,
|
||||
disableAutoSave: false,
|
||||
triggerAutoSave: false,
|
||||
showNotifications: false,
|
||||
};
|
||||
|
||||
export default memo(FunnelConfiguration);
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
&__title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.drag-icon {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
&__title {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
@@ -69,14 +79,12 @@
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
padding-left: 6px;
|
||||
padding-left: 12px;
|
||||
.ant-form-item {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.drag-icon {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import './FunnelStep.styles.scss';
|
||||
|
||||
import { Button, Divider, Dropdown, Form, Space, Switch, Tooltip } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { Button, Divider, Form, Switch, Tooltip } from 'antd';
|
||||
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { ChevronDown, GripVertical, HardHat, PencilLine } from 'lucide-react';
|
||||
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
|
||||
import { HardHat, PencilLine } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FunnelStepData } from 'types/api/traceFunnels';
|
||||
@@ -37,16 +35,17 @@ function FunnelStep({
|
||||
false,
|
||||
);
|
||||
|
||||
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||
(option) => ({
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
style:
|
||||
option.value === stepData.latency_pointer
|
||||
? { backgroundColor: 'var(--bg-slate-100)' }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
// temporarily hide latency pointer, as it breaks some edge cases (ref: https://signoz-team.slack.com/archives/C089MNX4Y90/p1748600682066499?thread_ts=1748599673.171759&cid=C089MNX4Y90)
|
||||
// const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||
// (option) => ({
|
||||
// key: option.value,
|
||||
// label: option.key,
|
||||
// style:
|
||||
// option.value === stepData.latency_pointer
|
||||
// ? { backgroundColor: 'var(--bg-slate-100)' }
|
||||
// : {},
|
||||
// }),
|
||||
// );
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
@@ -75,11 +74,17 @@ function FunnelStep({
|
||||
<Form form={form}>
|
||||
<div className="funnel-step__header">
|
||||
<div className="funnel-step-details">
|
||||
{stepData.name ? (
|
||||
<div className="funnel-step-details__title">{stepData.name}</div>
|
||||
) : (
|
||||
<div className="funnel-step-details__title">Step {index + 1}</div>
|
||||
)}
|
||||
<div className="funnel-step-details__title-container">
|
||||
{/* TODO(shaheer): uncomment after adding support for dragging the steps */}
|
||||
{/* <div className="drag-icon">
|
||||
<GripVertical size={14} color="var(--bg-slate-200)" />
|
||||
</div> */}
|
||||
{stepData.name ? (
|
||||
<div className="funnel-step-details__title">{stepData.name}</div>
|
||||
) : (
|
||||
<div className="funnel-step-details__title">Step {index + 1}</div>
|
||||
)}
|
||||
</div>
|
||||
{!!stepData.description && (
|
||||
<div className="funnel-step-details__description">
|
||||
{stepData.description}
|
||||
@@ -113,9 +118,6 @@ function FunnelStep({
|
||||
</div>
|
||||
</div>
|
||||
<div className="funnel-step__content">
|
||||
<div className="drag-icon">
|
||||
<GripVertical size={14} color="var(--bg-slate-200)" />
|
||||
</div>
|
||||
<div className="filters">
|
||||
<div className="filters__service-and-span">
|
||||
<div className="service">
|
||||
@@ -176,7 +178,8 @@ function FunnelStep({
|
||||
/>
|
||||
<div className="error__label">Errors</div>
|
||||
</div>
|
||||
<div className="latency-pointer">
|
||||
{/* temporarily hide latency pointer, as it breaks some edge cases (ref: https://signoz-team.slack.com/archives/C089MNX4Y90/p1748600682066499?thread_ts=1748599673.171759&cid=C089MNX4Y90) */}
|
||||
{/* <div className="latency-pointer">
|
||||
<div className="latency-pointer__label">Latency pointer</div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
@@ -197,7 +200,7 @@ function FunnelStep({
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
.steps-content {
|
||||
height: calc(
|
||||
100vh - 253px
|
||||
); // 64px (footer) + 12 (steps gap) + 32 (steps header) + 16 (steps padding) + 50 (breadcrumb) + 34 (description) + 45 (steps footer) = 219px
|
||||
overflow-y: auto;
|
||||
.ant-btn {
|
||||
box-shadow: none;
|
||||
&-icon {
|
||||
|
||||
@@ -2,7 +2,6 @@ import './StepsContent.styles.scss';
|
||||
|
||||
import { Button, Steps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { PlusIcon, Undo2 } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { memo, useCallback } from 'react';
|
||||
@@ -37,70 +36,68 @@ function StepsContent({
|
||||
|
||||
return (
|
||||
<div className="steps-content">
|
||||
<OverlayScrollbar>
|
||||
<Steps direction="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<Step
|
||||
key={`step-${index + 1}`}
|
||||
description={
|
||||
<div className="steps-content__description">
|
||||
<div className="funnel-step-wrapper">
|
||||
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
|
||||
{isTraceDetailsPage && span && (
|
||||
<Button
|
||||
type="default"
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
step.service_name === span.serviceName &&
|
||||
step.span_name === span.name
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
// the latency type should be sent with the n+1th step
|
||||
<InterStepConfig index={index + 1} step={steps[index + 1]} />
|
||||
<Steps direction="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<Step
|
||||
key={`step-${index + 1}`}
|
||||
description={
|
||||
<div className="steps-content__description">
|
||||
<div className="funnel-step-wrapper">
|
||||
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
|
||||
{isTraceDetailsPage && span && (
|
||||
<Button
|
||||
type="default"
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
step.service_name === span.serviceName &&
|
||||
step.span_name === span.name
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* For now we are only supporting 3 steps */}
|
||||
{steps.length < 3 && (
|
||||
<Step
|
||||
className="steps-content__add-step"
|
||||
description={
|
||||
!isTraceDetailsPage ? (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add Funnel Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddForNewStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add for new Step
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Steps>
|
||||
</OverlayScrollbar>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
// the latency type should be sent with the n+1th step
|
||||
<InterStepConfig index={index + 1} step={steps[index + 1]} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* For now we are only supporting 3 steps */}
|
||||
{steps.length < 3 && (
|
||||
<Step
|
||||
className="steps-content__add-step"
|
||||
description={
|
||||
!isTraceDetailsPage ? (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add Funnel Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
className="steps-content__add-btn"
|
||||
onClick={handleAddForNewStep}
|
||||
icon={<PlusIcon size={14} />}
|
||||
>
|
||||
Add for new Step
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
&--run {
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
&--updating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import './StepsFooter.styles.scss';
|
||||
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Skeleton, Spin } from 'antd';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Cone, Play, RefreshCcw } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useIsFetching, useQueryClient } from 'react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useIsFetching, useIsMutating } from 'react-query';
|
||||
|
||||
const useFunnelResultsLoading = (): boolean => {
|
||||
const { funnelId } = useFunnelContext();
|
||||
@@ -56,25 +57,11 @@ function ValidTracesCount(): JSX.Element {
|
||||
hasIncompleteStepFields,
|
||||
validTracesCount,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
} = useFunnelContext();
|
||||
const queryClient = useQueryClient();
|
||||
const validationQueryKey = useMemo(
|
||||
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
|
||||
[funnelId, selectedTime],
|
||||
);
|
||||
const validationStatus = queryClient.getQueryData(validationQueryKey);
|
||||
|
||||
useEffect(() => {
|
||||
// Show loading state immediately when fields become valid
|
||||
if (hasIncompleteStepFields && validationStatus !== 'pending') {
|
||||
queryClient.setQueryData(validationQueryKey, 'pending');
|
||||
}
|
||||
}, [
|
||||
hasIncompleteStepFields,
|
||||
queryClient,
|
||||
validationQueryKey,
|
||||
validationStatus,
|
||||
const isFunnelUpdateMutating = useIsMutating([
|
||||
REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS,
|
||||
funnelId,
|
||||
]);
|
||||
|
||||
if (hasAllEmptyStepFields) {
|
||||
@@ -91,7 +78,7 @@ function ValidTracesCount(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (isValidateStepsLoading || validationStatus === 'pending') {
|
||||
if (isValidateStepsLoading || isFunnelUpdateMutating) {
|
||||
return <Skeleton.Button size="small" />;
|
||||
}
|
||||
|
||||
@@ -111,10 +98,16 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
validTracesCount,
|
||||
handleRunFunnel,
|
||||
hasFunnelBeenExecuted,
|
||||
funnelId,
|
||||
} = useFunnelContext();
|
||||
|
||||
const isFunnelResultsLoading = useFunnelResultsLoading();
|
||||
|
||||
const isFunnelUpdateMutating = useIsMutating([
|
||||
REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS,
|
||||
funnelId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="steps-footer">
|
||||
<div className="steps-footer__left">
|
||||
@@ -124,6 +117,16 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
<ValidTracesCount />
|
||||
</div>
|
||||
<div className="steps-footer__right">
|
||||
{!!isFunnelUpdateMutating && (
|
||||
<div className="steps-footer__button steps-footer__button--updating">
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ color: 'grey' }} />}
|
||||
size="small"
|
||||
/>
|
||||
Updating
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasFunnelBeenExecuted ? (
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
|
||||
@@ -3,7 +3,7 @@ import './FunnelResults.styles.scss';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useIsMutating } from 'react-query';
|
||||
|
||||
import EmptyFunnelResults from './EmptyFunnelResults';
|
||||
import FunnelGraph from './FunnelGraph';
|
||||
@@ -18,14 +18,11 @@ function FunnelResults(): JSX.Element {
|
||||
hasAllEmptyStepFields,
|
||||
hasFunnelBeenExecuted,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
} = useFunnelContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const validateQueryData = queryClient.getQueryData([
|
||||
REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS,
|
||||
const isFunnelUpdateMutating = useIsMutating([
|
||||
REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
]);
|
||||
|
||||
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
|
||||
@@ -38,7 +35,7 @@ function FunnelResults(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
|
||||
if (isValidateStepsLoading || validateQueryData === 'pending') {
|
||||
if (isValidateStepsLoading || isFunnelUpdateMutating) {
|
||||
return <Spinner size="large" />;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user