mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-30 12:30:59 +00:00
Compare commits
15 Commits
update-PR-
...
feat/handl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd300adbc6 | ||
|
|
675728acf5 | ||
|
|
b63d1b0d1f | ||
|
|
f47b5cc4d6 | ||
|
|
f362200b22 | ||
|
|
07bb88e0ec | ||
|
|
6786767158 | ||
|
|
67082e9ff8 | ||
|
|
2040903fe5 | ||
|
|
b4dd5cb245 | ||
|
|
ee84efa73d | ||
|
|
ac11393491 | ||
|
|
9ad0ac694a | ||
|
|
e27b50c0fa | ||
|
|
4e4942f646 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,7 +2,7 @@
|
||||
# Owners are automatically requested for review for PRs that changes code
|
||||
# that they own.
|
||||
|
||||
/frontend/ @SigNoz/frontend-maintainers
|
||||
/frontend/ @YounixM @aks07
|
||||
|
||||
# Onboarding
|
||||
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
|
||||
|
||||
14
.github/pull_request_template.md
vendored
14
.github/pull_request_template.md
vendored
@@ -11,20 +11,6 @@
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
> Fill this only if the change affects users, APIs, UI, or documented behavior.
|
||||
Mention as N/A for internal refactors or non-user-visible changes.
|
||||
|
||||
**Deployment Type:** Cloud / OSS / Enterprise
|
||||
|
||||
**Type:** Feature / Bug Fix / Maintenance
|
||||
|
||||
**Description:** Short, user-facing summary of the change
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Required: Add Relevant Labels
|
||||
|
||||
> ⚠️ **Manually add appropriate labels in the PR sidebar**
|
||||
|
||||
16
.github/workflows/goci.yaml
vendored
16
.github/workflows/goci.yaml
vendored
@@ -73,19 +73,3 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
make docker-build-enterprise
|
||||
openapi:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
|
||||
|
||||
24
.github/workflows/integrationci.yaml
vendored
24
.github/workflows/integrationci.yaml
vendored
@@ -9,29 +9,6 @@ on:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
fmtlint:
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: poetry
|
||||
run: |
|
||||
python -m pip install poetry==2.1.2
|
||||
python -m poetry config virtualenvs.in-project true
|
||||
cd tests/integration && poetry install --no-root
|
||||
- name: fmt
|
||||
run: |
|
||||
make py-fmt
|
||||
- name: lint
|
||||
run: |
|
||||
make py-lint
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -44,7 +21,6 @@ jobs:
|
||||
- dashboard
|
||||
- querier
|
||||
- ttl
|
||||
- preference
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,7 +49,6 @@ ee/query-service/tests/test-deploy/data/
|
||||
# local data
|
||||
*.backup
|
||||
*.db
|
||||
**/db
|
||||
/deploy/docker/clickhouse-setup/data/
|
||||
/deploy/docker-swarm/clickhouse-setup/data/
|
||||
bin/
|
||||
|
||||
6
Makefile
6
Makefile
@@ -72,12 +72,6 @@ devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhou
|
||||
@echo " - ClickHouse: http://localhost:8123"
|
||||
@echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318"
|
||||
|
||||
.PHONY: devenv-clickhouse-clean
|
||||
devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
|
||||
@echo "Removing ClickHouse data..."
|
||||
@rm -rf .devenv/docker/clickhouse/fs/tmp/*
|
||||
@echo "ClickHouse data cleaned!"
|
||||
|
||||
##############################################################
|
||||
# go commands
|
||||
##############################################################
|
||||
|
||||
@@ -13,7 +13,6 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
FROM node:18-bullseye AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 yarn install
|
||||
RUN CI=1 yarn build
|
||||
|
||||
FROM golang:1.24-bullseye
|
||||
|
||||
ARG OS="linux"
|
||||
@@ -32,6 +40,8 @@ COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
ENTRYPOINT ["/root/signoz", "server"]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
FROM node:18-bullseye AS build
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 yarn install
|
||||
RUN CI=1 yarn build
|
||||
|
||||
FROM golang:1.24-bullseye
|
||||
|
||||
ARG OS="linux"
|
||||
ARG TARGETARCH
|
||||
ARG ZEUSURL
|
||||
|
||||
# This path is important for stacktraces
|
||||
WORKDIR $GOPATH/src/github.com/signoz/signoz
|
||||
WORKDIR /root
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
make \
|
||||
pkg-config \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
ENTRYPOINT ["/root/signoz", "server"]
|
||||
@@ -13,7 +13,6 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate artifacts",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||
}
|
||||
|
||||
registerGenerateOpenAPI(generateCmd)
|
||||
|
||||
parentCmd.AddCommand(generateCmd)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
|
||||
openapiCmd := &cobra.Command{
|
||||
Use: "openapi",
|
||||
Short: "Generate OpenAPI schema for SigNoz",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return runGenerateOpenAPI(currCmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
parentCmd.AddCommand(openapiCmd)
|
||||
}
|
||||
|
||||
func runGenerateOpenAPI(ctx context.Context) error {
|
||||
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,13 +3,6 @@
|
||||
# Do not modify this file
|
||||
#
|
||||
|
||||
##################### Global #####################
|
||||
global:
|
||||
# the url under which the signoz apiserver is externally reachable.
|
||||
external_url: <unset>
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
|
||||
##################### Version #####################
|
||||
version:
|
||||
banner:
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.105.1
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.105.1
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.105.1}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.105.1}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
2428
docs/api/openapi.yml
2428
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -1,179 +0,0 @@
|
||||
# Handler
|
||||
|
||||
Handlers in SigNoz are responsible for exposing module functionality over HTTP. They are thin adapters that:
|
||||
|
||||
- Decode incoming HTTP requests
|
||||
- Call the appropriate module layer
|
||||
- Return structured responses (or errors) in a consistent format
|
||||
- Describe themselves for OpenAPI generation
|
||||
|
||||
They are **not** the place for complex business logic; that belongs in modules (for example, `pkg/modules/user`, `pkg/modules/session`, etc).
|
||||
|
||||
## How are handlers structured?
|
||||
|
||||
At a high level, a typical flow looks like this:
|
||||
|
||||
1. A `Handler` interface is defined in the module (for example, `user.Handler`, `session.Handler`, `organization.Handler`).
|
||||
2. The `apiserver` provider wires those handlers into HTTP routes using Gorilla `mux.Router`.
|
||||
|
||||
Each route wraps a module handler method with the following:
|
||||
- Authorization middleware (from `pkg/http/middleware`)
|
||||
- A generic HTTP `handler.Handler` (from `pkg/http/handler`)
|
||||
- An `OpenAPIDef` that describes the operation for OpenAPI generation
|
||||
|
||||
For example, in `pkg/apiserver/signozapiserver`:
|
||||
|
||||
```go
|
||||
if err := router.Handle("/api/v1/invite", handler.New(
|
||||
provider.authZ.AdminAccess(provider.userHandler.CreateInvite),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create invite",
|
||||
Description: "This endpoint creates an invite for a user",
|
||||
Request: new(types.PostableInvite),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Invite),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
In this pattern:
|
||||
|
||||
- `provider.userHandler.CreateInvite` is a handler method.
|
||||
- `provider.authZ.AdminAccess(...)` wraps that method with authorization checks and context setup.
|
||||
- `handler.New` converts it into an HTTP handler and wires it to OpenAPI via the `OpenAPIDef`.
|
||||
|
||||
## How to write a new handler method?
|
||||
|
||||
When adding a new endpoint:
|
||||
|
||||
1. Add a method to the appropriate module `Handler` interface.
|
||||
2. Implement that method in the module.
|
||||
3. Register the method in `signozapiserver` with the correct route, HTTP method, auth, and `OpenAPIDef`.
|
||||
|
||||
### 1. Extend an existing `Handler` interface or create a new one
|
||||
|
||||
Find the module in `pkg/modules/<name>` and extend its `Handler` interface with a new method that receives an `http.ResponseWriter` and `*http.Request`. For example:
|
||||
|
||||
```go
|
||||
type Handler interface {
|
||||
// existing methods...
|
||||
CreateThing(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
```
|
||||
|
||||
Keep the method focused on HTTP concerns and delegate business logic to the module.
|
||||
|
||||
### 2. Implement the handler method
|
||||
|
||||
In the module implementation, implement the new method. A typical implementation:
|
||||
|
||||
- Extracts authentication and organization context from `req.Context()`
|
||||
- Decodes the request body into a `types.*` struct using the `binding` package
|
||||
- Calls module functions
|
||||
- Uses the `render` package to write responses or errors
|
||||
|
||||
```go
|
||||
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
// Extract authentication and organization context from req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the request body into a `types.*` struct using the `binding` package
|
||||
var in types.PostableThing
|
||||
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call module functions
|
||||
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the `render` package to write responses or errors
|
||||
render.Success(rw, http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the handler in `signozapiserver`
|
||||
|
||||
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
|
||||
|
||||
```go
|
||||
if err := router.Handle("/api/v1/things", handler.New(
|
||||
provider.authZ.AdminAccess(provider.thingHandler.CreateThing),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateThing",
|
||||
Tags: []string{"things"},
|
||||
Summary: "Create thing",
|
||||
Description: "This endpoint creates a thing",
|
||||
Request: new(types.PostableThing),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.GettableThing),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update the OpenAPI spec
|
||||
|
||||
Run the following command to update the OpenAPI spec:
|
||||
|
||||
```bash
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
```
|
||||
|
||||
This will update the OpenAPI spec in `docs/api/openapi.yml` to reflect the new endpoint.
|
||||
|
||||
## How does OpenAPI integration work?
|
||||
|
||||
The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAPIDef`. This drives the generated OpenAPI document.
|
||||
|
||||
- **ID**: A unique identifier for the operation (used as the `operationId`).
|
||||
- **Tags**: Logical grouping for the operation (for example, `"users"`, `"sessions"`, `"orgs"`).
|
||||
- **Summary / Description**: Human-friendly documentation.
|
||||
- **Request / RequestContentType**:
|
||||
- `Request` is a Go type that describes the request body or form.
|
||||
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
|
||||
- **Response / ResponseContentType**:
|
||||
- `Response` is the Go type for the successful response payload.
|
||||
- `ResponseContentType` is usually `"application/json"`; use `""` for responses without a body.
|
||||
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
|
||||
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
|
||||
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
|
||||
|
||||
The generic handler:
|
||||
|
||||
- Automatically appends `401`, `403`, and `500` to `ErrorStatusCodes` when appropriate.
|
||||
- Registers request and response schemas with the OpenAPI reflector so they appear in `docs/api/openapi.yml`.
|
||||
|
||||
See existing examples in:
|
||||
|
||||
- `addUserRoutes` (for typical JSON request/response)
|
||||
- `addSessionRoutes` (for form-encoded and redirect flows)
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- **Keep handlers thin**: focus on HTTP concerns and delegate logic to modules/services.
|
||||
- **Always register routes through `signozapiserver`** using `handler.New` and a complete `OpenAPIDef`.
|
||||
- **Choose accurate request/response types** from the `types` packages so OpenAPI schemas are correct.
|
||||
@@ -94,6 +94,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// paid plans specific routes
|
||||
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // http profiler
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
@@ -107,8 +106,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
signoz.Prometheus,
|
||||
signoz.Modules.OrgGetter,
|
||||
signoz.Querier,
|
||||
signoz.Instrumentation.ToProviderSettings(),
|
||||
signoz.QueryParser,
|
||||
signoz.Instrumentation.Logger(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -245,11 +243,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
@@ -260,7 +253,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err = web.AddToRouter(r)
|
||||
err := web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -355,8 +348,8 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
@@ -366,7 +359,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
|
||||
Logger: zap.L(),
|
||||
Reader: ch,
|
||||
Querier: querier,
|
||||
SLogger: providerSettings.Logger,
|
||||
SLogger: logger,
|
||||
Cache: cache,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"@signozhq/calendar": "0.0.0",
|
||||
"@signozhq/callout": "0.0.2",
|
||||
"@signozhq/checkbox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -105,6 +104,7 @@
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"jest": "^27.5.1",
|
||||
"js-base64": "^3.7.2",
|
||||
"kbar": "0.1.0-beta.48",
|
||||
"less": "^4.1.2",
|
||||
"less-loader": "^10.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -4,9 +4,8 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -25,9 +24,9 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
|
||||
import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
@@ -365,13 +364,10 @@ function App(): JSX.Element {
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<CmdKProvider>
|
||||
<KBarCommandPaletteProvider>
|
||||
<KBarCommandPalette />
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{isLoggedInState && <CmdKPalette userRole={user.role} />}
|
||||
{isLoggedInState && (
|
||||
<ShiftHoldOverlayController userRole={user.role} />
|
||||
)}
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
@@ -402,7 +398,7 @@ function App(): JSX.Element {
|
||||
</PrivateRoute>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</CmdKProvider>
|
||||
</KBarCommandPaletteProvider>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
|
||||
2
frontend/src/auto-import-registry.d.ts
vendored
2
frontend/src/auto-import-registry.d.ts
vendored
@@ -14,8 +14,6 @@ import '@signozhq/badge';
|
||||
import '@signozhq/button';
|
||||
import '@signozhq/calendar';
|
||||
import '@signozhq/callout';
|
||||
import '@signozhq/checkbox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.field-variant-badges-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-badge {
|
||||
&.data-type {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--bg-vanilla-100) 8%, transparent);
|
||||
white-space: nowrap;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&.type-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0px 6px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: 50px;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&.attribute {
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
|
||||
.dot {
|
||||
background-color: var(--bg-sienna-400);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--bg-sienna-400);
|
||||
}
|
||||
}
|
||||
|
||||
&.resource {
|
||||
background: color-mix(in srgb, var(--bg-aqua-400) 10%, transparent);
|
||||
color: var(--bg-aqua-400);
|
||||
|
||||
.dot {
|
||||
background-color: var(--bg-aqua-400);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--bg-aqua-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import './FieldVariantBadges.styles.scss';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
/**
|
||||
* Field contexts that should display badges
|
||||
*/
|
||||
export enum AllowedFieldContext {
|
||||
Attribute = 'attribute',
|
||||
Resource = 'resource',
|
||||
}
|
||||
|
||||
const ALLOWED_FIELD_CONTEXTS = new Set<string>([
|
||||
AllowedFieldContext.Attribute,
|
||||
AllowedFieldContext.Resource,
|
||||
]);
|
||||
|
||||
interface FieldVariantBadgesProps {
|
||||
fieldDataType?: string;
|
||||
fieldContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a fieldContext badge should be displayed
|
||||
* Only shows badges for contexts in ALLOWED_FIELD_CONTEXTS
|
||||
*/
|
||||
const shouldShowFieldContextBadge = (
|
||||
fieldContext: string | undefined | null,
|
||||
): boolean => {
|
||||
if (!fieldContext) {
|
||||
return false;
|
||||
}
|
||||
return ALLOWED_FIELD_CONTEXTS.has(fieldContext);
|
||||
};
|
||||
|
||||
function FieldVariantBadges({
|
||||
fieldDataType,
|
||||
fieldContext,
|
||||
}: FieldVariantBadgesProps): JSX.Element | null {
|
||||
// If neither value exists, don't render anything
|
||||
if (!fieldDataType && !fieldContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if fieldContext should be displayed
|
||||
const showFieldContext =
|
||||
fieldContext && shouldShowFieldContextBadge(fieldContext);
|
||||
|
||||
return (
|
||||
<span className="field-variant-badges-container">
|
||||
{fieldDataType && (
|
||||
<span className="field-badge data-type">{fieldDataType}</span>
|
||||
)}
|
||||
{showFieldContext && (
|
||||
<section className={cx('field-badge type-tag', fieldContext)}>
|
||||
<div className="dot" />
|
||||
<span className="text">{fieldContext}</span>
|
||||
</section>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
FieldVariantBadges.defaultProps = {
|
||||
fieldDataType: undefined,
|
||||
fieldContext: undefined,
|
||||
};
|
||||
|
||||
export default FieldVariantBadges;
|
||||
@@ -0,0 +1,152 @@
|
||||
.kbar-command-palette__positioner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.kbar-command-palette__animator {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: var(--bg-ink-500);
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-ink-200);
|
||||
color: var(--text-vanilla-100);
|
||||
outline: none;
|
||||
background-color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-robin-500);
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__shortcut {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--text-vanilla-300);
|
||||
text-transform: uppercase;
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.kbar-command-palette__positioner {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
color: var(--text-ink-500);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import './KBarCommandPalette.scss';
|
||||
|
||||
import {
|
||||
KBarAnimator,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarResults,
|
||||
KBarSearch,
|
||||
useMatches,
|
||||
} from 'kbar';
|
||||
|
||||
function Results(): JSX.Element {
|
||||
const { results } = useMatches();
|
||||
|
||||
const renderResults = ({
|
||||
item,
|
||||
active,
|
||||
}: {
|
||||
item: any;
|
||||
active: boolean;
|
||||
}): JSX.Element =>
|
||||
typeof item === 'string' ? (
|
||||
<div className="kbar-command-palette__section">{item}</div>
|
||||
) : (
|
||||
<div
|
||||
className={`kbar-command-palette__item ${
|
||||
active ? 'kbar-command-palette__item--active' : ''
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
{item.shortcut?.length ? (
|
||||
<span className="kbar-command-palette__shortcut">
|
||||
{item.shortcut.map((sc: string) => (
|
||||
<kbd key={sc} className="kbar-command-palette__key">
|
||||
{sc}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="kbar-command-palette__results-container">
|
||||
<KBarResults items={results} onRender={renderResults} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KBarCommandPalette(): JSX.Element {
|
||||
return (
|
||||
<KBarPortal>
|
||||
<KBarPositioner className="kbar-command-palette__positioner">
|
||||
<KBarAnimator className="kbar-command-palette__animator">
|
||||
<div className="kbar-command-palette__card">
|
||||
<KBarSearch
|
||||
className="kbar-command-palette__search"
|
||||
placeholder="Search or type a command..."
|
||||
/>
|
||||
<Results />
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export default KBarCommandPalette;
|
||||
@@ -1,15 +1,11 @@
|
||||
.log-field-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
}
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
.log-field-key {
|
||||
padding-right: 5px;
|
||||
color: var(--text-vanilla-400, #c0c1c3);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&.small {
|
||||
font-size: 11px;
|
||||
@@ -26,20 +22,6 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
.log-field-key {
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 20vw;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
.log-field-key-colon {
|
||||
min-width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-value {
|
||||
color: var(--text-vanilla-400, #c0c1c3);
|
||||
font-size: 14px;
|
||||
@@ -176,8 +158,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
.log-field-key {
|
||||
color: var(--text-slate-400);
|
||||
}
|
||||
.log-value {
|
||||
@@ -189,10 +170,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.log-field-key,
|
||||
.log-field-key-colon {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,13 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { Container, LogContainer, LogText } from './styles';
|
||||
import {
|
||||
Container,
|
||||
LogContainer,
|
||||
LogText,
|
||||
Text,
|
||||
TextContainer,
|
||||
} from './styles';
|
||||
import { isValidLogField } from './util';
|
||||
|
||||
interface LogFieldProps {
|
||||
@@ -52,18 +58,16 @@ function LogGeneralField({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="log-field-container">
|
||||
<p className={cx('log-field-key', fontSize)} title={fieldKey}>
|
||||
{fieldKey}
|
||||
</p>
|
||||
<span className={cx('log-field-key-colon', fontSize)}> : </span>
|
||||
<TextContainer>
|
||||
<Text ellipsis type="secondary" className={cx('log-field-key', fontSize)}>
|
||||
{`${fieldKey} : `}
|
||||
</Text>
|
||||
<LogText
|
||||
dangerouslySetInnerHTML={html}
|
||||
className={cx('log-value', fontSize)}
|
||||
title={fieldValue}
|
||||
linesPerRow={linesPerRow > 1 ? linesPerRow : undefined}
|
||||
/>
|
||||
</div>
|
||||
</TextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Card } from 'antd';
|
||||
import { Card, Typography } from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import styled from 'styled-components';
|
||||
import { getActiveLogBackground } from 'utils/logs';
|
||||
@@ -46,6 +46,19 @@ export const Container = styled(Card)<{
|
||||
getActiveLogBackground($isActiveLog, $isDarkMode, $logType)}
|
||||
`;
|
||||
|
||||
export const Text = styled(Typography.Text)`
|
||||
&&& {
|
||||
min-width: 2.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextContainer = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const LogContainer = styled.div<LogContainerProps>`
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { renderHook, RenderHookResult } from '@testing-library/react';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
mockAllAvailableKeys,
|
||||
mockConflictingFieldsByContext,
|
||||
mockConflictingFieldsByDatatype,
|
||||
} from 'container/OptionsMenu/__tests__/mockData';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import { useTableView } from '../useTableView';
|
||||
|
||||
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
|
||||
const SERVICE_NAME_COLUMN_UNDEFINED_ERROR = 'serviceNameColumn is undefined';
|
||||
|
||||
// Mock useTimezone hook
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
formatTimezoneAdjustedTimestamp: (input: string | number) => string;
|
||||
} => ({
|
||||
formatTimezoneAdjustedTimestamp: jest.fn((input: string | number): string => {
|
||||
if (typeof input === 'string') {
|
||||
return new Date(input).toISOString();
|
||||
}
|
||||
return new Date(input / 1e6).toISOString();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useIsDarkMode hook
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
describe('useTableView - Column Headers', () => {
|
||||
const HTTP_STATUS_CODE = 'http.status_code';
|
||||
|
||||
const mockLogs: ILog[] = [
|
||||
({
|
||||
id: '1',
|
||||
body: 'Test log',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
[HTTP_STATUS_CODE]: '200',
|
||||
} as unknown) as ILog,
|
||||
];
|
||||
|
||||
const renderUseTableView = (
|
||||
fields: TelemetryFieldKey[],
|
||||
allAvailableKeys = mockAllAvailableKeys,
|
||||
): RenderHookResult<ReturnType<typeof useTableView>, unknown> =>
|
||||
renderHook(() =>
|
||||
useTableView({
|
||||
logs: mockLogs,
|
||||
fields: fields as IField[],
|
||||
linesPerRow: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
allAvailableKeys,
|
||||
}),
|
||||
);
|
||||
|
||||
it('shows datatype in column header for conflicting columns', () => {
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByDatatype[0], // string variant
|
||||
];
|
||||
|
||||
const { result } = renderUseTableView(fields);
|
||||
const { columns } = result.current;
|
||||
|
||||
const statusCodeColumn = columns.find(
|
||||
(col): col is ColumnType<Record<string, unknown>> =>
|
||||
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
|
||||
);
|
||||
|
||||
expect(statusCodeColumn).toBeDefined();
|
||||
expect(statusCodeColumn?.title).toBeDefined();
|
||||
|
||||
if (!statusCodeColumn) {
|
||||
throw new Error(COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(statusCodeColumn);
|
||||
expect(container.textContent).toContain('http.status_code (string)');
|
||||
expect(container.textContent).toContain('string');
|
||||
});
|
||||
|
||||
it('shows tooltip icon when unselected conflicting variant exists', () => {
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByDatatype[0], // Only string variant selected
|
||||
];
|
||||
|
||||
const { result } = renderUseTableView(fields, mockAllAvailableKeys); // Contains number variant
|
||||
const { columns } = result.current;
|
||||
|
||||
const statusCodeColumn = columns.find(
|
||||
(col): col is ColumnType<Record<string, unknown>> =>
|
||||
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
|
||||
);
|
||||
|
||||
expect(statusCodeColumn).toBeDefined();
|
||||
|
||||
// Verify that _hasUnselectedConflict metadata is set correctly
|
||||
const columnRecord = statusCodeColumn as Record<string, unknown>;
|
||||
expect(columnRecord._hasUnselectedConflict).toBe(true);
|
||||
|
||||
if (!statusCodeColumn) {
|
||||
throw new Error(COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(statusCodeColumn);
|
||||
const tooltipIcon = container.querySelector('.anticon-info-circle');
|
||||
expect(tooltipIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides tooltip icon when all conflicting variants are selected', () => {
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
...mockConflictingFieldsByDatatype, // Both variants selected
|
||||
];
|
||||
|
||||
const { result } = renderUseTableView(fields);
|
||||
const { columns } = result.current;
|
||||
|
||||
const statusCodeColumn = columns.find(
|
||||
(col): col is ColumnType<Record<string, unknown>> =>
|
||||
'dataIndex' in col && col.dataIndex === HTTP_STATUS_CODE,
|
||||
);
|
||||
|
||||
expect(statusCodeColumn).toBeDefined();
|
||||
|
||||
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
|
||||
const columnRecord = statusCodeColumn as Record<string, unknown>;
|
||||
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
|
||||
|
||||
if (!statusCodeColumn) {
|
||||
throw new Error(COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(statusCodeColumn);
|
||||
const tooltipIcon = container.querySelector('.anticon-info-circle');
|
||||
expect(tooltipIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows context in header for attribute/resource conflicting fields', () => {
|
||||
// When same datatype but different contexts, it shows context
|
||||
const fields: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByContext[0], // resource variant
|
||||
mockConflictingFieldsByContext[1], // attribute variant - both have same datatype
|
||||
];
|
||||
|
||||
const { result } = renderUseTableView(fields);
|
||||
const { columns } = result.current;
|
||||
|
||||
const serviceNameColumn = columns.find(
|
||||
(col): col is ColumnType<Record<string, unknown>> =>
|
||||
'dataIndex' in col && col.dataIndex === 'service.name',
|
||||
);
|
||||
|
||||
expect(serviceNameColumn).toBeDefined();
|
||||
|
||||
if (!serviceNameColumn) {
|
||||
throw new Error(SERVICE_NAME_COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(serviceNameColumn);
|
||||
expect(container.textContent).toContain('service.name (resource)');
|
||||
expect(container.textContent).toContain('resource');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -28,6 +29,7 @@ export type UseTableViewProps = {
|
||||
activeLogIndex?: number;
|
||||
activeContextLog?: ILog | null;
|
||||
isListViewPanel?: boolean;
|
||||
allAvailableKeys?: TelemetryFieldKey[];
|
||||
} & LogsTableViewProps;
|
||||
|
||||
export type ActionsColumnProps = {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { ColumnsType } from 'antd/es/table';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getColumnTitleWithTooltip,
|
||||
getFieldVariantsByName,
|
||||
getUniqueColumnKey,
|
||||
hasMultipleVariants,
|
||||
} from 'container/OptionsMenu/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -31,6 +37,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
fontSize,
|
||||
appendTo = 'center',
|
||||
isListViewPanel,
|
||||
allAvailableKeys,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -50,30 +57,50 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
);
|
||||
|
||||
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
||||
// Group fields by name to analyze variants
|
||||
const fieldVariantsByName = getFieldVariantsByName(fields);
|
||||
|
||||
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
||||
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
|
||||
.map(({ name }) => ({
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
accessorKey: name,
|
||||
id: name.toLowerCase().replace(/\./g, '_'),
|
||||
key: name,
|
||||
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
props: {
|
||||
style: isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode),
|
||||
},
|
||||
children: (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: linesPerRow }}
|
||||
className={cx('paragraph', fontSize)}
|
||||
>
|
||||
{field}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
}),
|
||||
}));
|
||||
.map((field) => {
|
||||
const hasVariants = hasMultipleVariants(
|
||||
field.name || '',
|
||||
fields,
|
||||
allAvailableKeys,
|
||||
);
|
||||
const variants = fieldVariantsByName[field.name] || [];
|
||||
const { title, hasUnselectedConflict } = getColumnTitleWithTooltip(
|
||||
field,
|
||||
hasVariants,
|
||||
variants,
|
||||
fields,
|
||||
allAvailableKeys,
|
||||
);
|
||||
return {
|
||||
title,
|
||||
dataIndex: field.name,
|
||||
accessorKey: field.name,
|
||||
id: getUniqueColumnKey(field),
|
||||
key: getUniqueColumnKey(field),
|
||||
// Store metadata for header enhancement (will be rendered via custom header component)
|
||||
...(hasUnselectedConflict && { _hasUnselectedConflict: true }),
|
||||
render: (cellField): ColumnTypeRender<Record<string, unknown>> => ({
|
||||
props: {
|
||||
style: isListViewPanel
|
||||
? defaultListViewPanelStyle
|
||||
: getDefaultCellStyle(isDarkMode),
|
||||
},
|
||||
children: (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: linesPerRow }}
|
||||
className={cx('paragraph', fontSize)}
|
||||
>
|
||||
{cellField}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (isListViewPanel) {
|
||||
return [...fieldColumns];
|
||||
@@ -177,6 +204,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
fontSize,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
bodyColumnStyle,
|
||||
allAvailableKeys,
|
||||
]);
|
||||
|
||||
return { columns, dataSource: flattenLogData };
|
||||
|
||||
@@ -314,6 +314,23 @@
|
||||
background-color: var(--bg-ink-200);
|
||||
cursor: pointer;
|
||||
}
|
||||
.name-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -402,12 +419,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.name-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: calc(100% - 26px);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import './LogsFormatOptionsMenu.styles.scss';
|
||||
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
getNamesWithVariants,
|
||||
getUniqueColumnKey,
|
||||
hasMultipleVariants,
|
||||
} from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import {
|
||||
Check,
|
||||
@@ -26,6 +32,7 @@ interface LogsFormatOptionsMenuProps {
|
||||
config: OptionsMenuConfig;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function OptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
@@ -50,6 +57,11 @@ function OptionsMenu({
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
|
||||
// Detect which column names have multiple variants in dropdown options
|
||||
const namesWithVariantsInOptions = getNamesWithVariants(
|
||||
addColumn?.options || [],
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
if (!format) return;
|
||||
@@ -301,33 +313,46 @@ function OptionsMenu({
|
||||
)}
|
||||
|
||||
<div className="column-format-new-options" ref={listRef}>
|
||||
{addColumn?.options?.map(({ label, value }, index) => (
|
||||
<div
|
||||
className={cx('column-name', value === selectedValue && 'selected')}
|
||||
key={value}
|
||||
onMouseEnter={(): void => {
|
||||
if (!initialMouseEnterRef.current) {
|
||||
setSelectedValue(value as string | null);
|
||||
}
|
||||
{addColumn?.options?.map((option, index) => {
|
||||
const { label, value, fieldDataType, fieldContext } = option;
|
||||
return (
|
||||
<div
|
||||
className={cx('column-name', value === selectedValue && 'selected')}
|
||||
key={value}
|
||||
onMouseEnter={(): void => {
|
||||
if (!initialMouseEnterRef.current) {
|
||||
setSelectedValue(value as string | null);
|
||||
}
|
||||
|
||||
initialMouseEnterRef.current = true;
|
||||
}}
|
||||
onMouseMove={(): void => {
|
||||
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
|
||||
setSelectedValue(value as string | null);
|
||||
}}
|
||||
onClick={(eve): void => {
|
||||
eve.stopPropagation();
|
||||
handleColumnSelection(index, addColumn?.options || []);
|
||||
}}
|
||||
>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={label}>
|
||||
{label}
|
||||
</Tooltip>
|
||||
initialMouseEnterRef.current = true;
|
||||
}}
|
||||
onMouseMove={(): void => {
|
||||
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
|
||||
setSelectedValue(value as string | null);
|
||||
}}
|
||||
onClick={(eve): void => {
|
||||
eve.stopPropagation();
|
||||
handleColumnSelection(index, addColumn?.options || []);
|
||||
}}
|
||||
>
|
||||
<div className="name-wrapper">
|
||||
<Tooltip placement="left" title={label}>
|
||||
<span className="name">{label}</span>
|
||||
</Tooltip>
|
||||
{fieldDataType &&
|
||||
typeof label === 'string' &&
|
||||
namesWithVariantsInOptions.has(label) && (
|
||||
<span className="field-variant-badges">
|
||||
<FieldVariantBadges
|
||||
fieldDataType={fieldDataType}
|
||||
fieldContext={fieldContext}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,22 +441,38 @@ function OptionsMenu({
|
||||
)}
|
||||
|
||||
<div className="column-format">
|
||||
{addColumn?.value?.map(({ name }) => (
|
||||
<div className="column-name" key={name}>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={name}>
|
||||
{name}
|
||||
{addColumn?.value?.map((column) => {
|
||||
const uniqueKey = getUniqueColumnKey(column);
|
||||
const showBadge = hasMultipleVariants(
|
||||
column.name || '',
|
||||
addColumn?.value || [],
|
||||
addColumn?.allAvailableKeys,
|
||||
);
|
||||
return (
|
||||
<div className="column-name" key={uniqueKey}>
|
||||
<Tooltip placement="left" title={column.name}>
|
||||
<div className="name-wrapper">
|
||||
<span className="name">{column.name}</span>
|
||||
{showBadge && (
|
||||
<span className="field-variant-badges">
|
||||
<FieldVariantBadges
|
||||
fieldDataType={column.fieldDataType}
|
||||
fieldContext={column.fieldContext}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(uniqueKey)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{addColumn && addColumn?.value?.length === 0 && (
|
||||
<div className="column-name no-columns-selected">
|
||||
No columns selected
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
mockAllAvailableKeys,
|
||||
mockConflictingFieldsByContext,
|
||||
mockConflictingFieldsByDatatype,
|
||||
} from 'container/OptionsMenu/__tests__/mockData';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOptionsFromKeys } from 'container/OptionsMenu/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
const mockUpdateFormatting = jest.fn();
|
||||
const mockUpdateColumns = jest.fn();
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): any => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: {
|
||||
maxLines: 2,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LogsFormatOptionsMenu - Badge Display', () => {
|
||||
const FORMAT_BUTTON_TEST_ID = 'periscope-btn-format-options';
|
||||
const HTTP_STATUS_CODE = 'http.status_code';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function setup(configOverrides = {}): any {
|
||||
const items = [
|
||||
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
|
||||
{ key: 'list', label: 'Default' },
|
||||
{ key: 'table', label: 'Column', data: { title: 'columns' } },
|
||||
];
|
||||
|
||||
const formatOnChange = jest.fn();
|
||||
const maxLinesOnChange = jest.fn();
|
||||
const fontSizeOnChange = jest.fn();
|
||||
const onSelect = jest.fn();
|
||||
const onRemove = jest.fn();
|
||||
const onSearch = jest.fn();
|
||||
const onFocus = jest.fn();
|
||||
const onBlur = jest.fn();
|
||||
|
||||
const defaultConfig = {
|
||||
format: { value: 'table', onChange: formatOnChange },
|
||||
maxLines: { value: 2, onChange: maxLinesOnChange },
|
||||
fontSize: { value: FontSize.SMALL, onChange: fontSizeOnChange },
|
||||
addColumn: {
|
||||
isFetching: false,
|
||||
value: [],
|
||||
options: [],
|
||||
onFocus,
|
||||
onBlur,
|
||||
onSearch,
|
||||
onSelect,
|
||||
onRemove,
|
||||
allAvailableKeys: mockAllAvailableKeys,
|
||||
...configOverrides,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<LogsFormatOptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat="table"
|
||||
config={defaultConfig}
|
||||
/>,
|
||||
);
|
||||
|
||||
return {
|
||||
getByTestId,
|
||||
formatOnChange,
|
||||
maxLinesOnChange,
|
||||
fontSizeOnChange,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onSearch,
|
||||
onFocus,
|
||||
onBlur,
|
||||
};
|
||||
}
|
||||
|
||||
it('shows badges in dropdown options when searching for conflicting attributes', () => {
|
||||
const options = getOptionsFromKeys(mockConflictingFieldsByDatatype, []);
|
||||
|
||||
expect(options).toBeDefined();
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options?.[0]?.hasMultipleVariants).toBe(true);
|
||||
expect(options?.[1]?.hasMultipleVariants).toBe(true);
|
||||
expect(options?.[0]?.fieldDataType).toBe('string');
|
||||
expect(options?.[1]?.fieldDataType).toBe('number');
|
||||
});
|
||||
|
||||
it('shows badges in selected columns list after selecting conflicting attribute', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByDatatype[0], // Only string variant selected
|
||||
];
|
||||
|
||||
const { getByTestId } = setup({
|
||||
value: selectedColumns,
|
||||
});
|
||||
|
||||
// Open the popover menu
|
||||
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
|
||||
await user.click(formatButton);
|
||||
|
||||
// Wait for selected columns section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(HTTP_STATUS_CODE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Badge should appear even though only one variant is selected
|
||||
// because allAvailableKeys contains the conflicting variant
|
||||
const datatypeBadge = screen.queryByText('string');
|
||||
expect(datatypeBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows context badge only for attribute/resource conflicting fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByContext[0], // resource variant
|
||||
];
|
||||
|
||||
const { getByTestId } = setup({
|
||||
value: selectedColumns,
|
||||
});
|
||||
|
||||
// Open the popover menu
|
||||
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
|
||||
await user.click(formatButton);
|
||||
|
||||
// Wait for selected columns section
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Context badge should appear for resource
|
||||
const contextBadge = screen.queryByText('resource');
|
||||
expect(contextBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows datatype badge for conflicting fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span', // span context
|
||||
signal: 'traces',
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = setup({
|
||||
value: selectedColumns,
|
||||
allAvailableKeys: [
|
||||
...mockAllAvailableKeys,
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: 'number',
|
||||
fieldContext: 'span',
|
||||
signal: 'traces',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Open the popover menu
|
||||
const formatButton = getByTestId(FORMAT_BUTTON_TEST_ID);
|
||||
await user.click(formatButton);
|
||||
|
||||
// Wait for selected columns section
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(HTTP_STATUS_CODE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Datatype badge should appear
|
||||
const datatypeBadge = screen.queryByText('string');
|
||||
expect(datatypeBadge).toBeInTheDocument();
|
||||
|
||||
// Context badge should NOT appear for span context
|
||||
const contextBadge = screen.queryByText('span');
|
||||
expect(contextBadge).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ const ADD_ONS_KEYS = {
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
};
|
||||
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
@@ -41,14 +40,13 @@ const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
|
||||
[ADD_ONS_KEYS.LIMIT]: 'limit',
|
||||
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
|
||||
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart2 size={14} />,
|
||||
label: 'Group By',
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
key: 'group_by',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
@@ -56,7 +54,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
key: 'having',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -65,7 +63,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
key: 'order_by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -74,7 +72,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
key: 'limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -83,7 +81,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
key: 'legend_format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -94,7 +92,7 @@ const ADD_ONS = [
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
key: 'reduce_to',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -220,9 +218,10 @@ function QueryAddOns({
|
||||
);
|
||||
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
|
||||
|
||||
// Filter and set selected views: add-ons that are both active and available
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
ADD_ONS.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
@@ -301,7 +300,7 @@ function QueryAddOns({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
<div className="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
<div className="selected-add-ons-content">
|
||||
{selectedViews.find((view) => view.key === 'group_by') && (
|
||||
|
||||
@@ -43,10 +43,7 @@ function QueryAggregationOptions({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="query-aggregation-container"
|
||||
data-testid="query-aggregation-container"
|
||||
>
|
||||
<div className="query-aggregation-container">
|
||||
<div className="aggregation-container">
|
||||
<QueryAggregationSelect
|
||||
onChange={onChange}
|
||||
|
||||
@@ -114,9 +114,9 @@ function QuerySearch({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const handleQueryValidation = useCallback((newExpression: string): void => {
|
||||
const handleQueryValidation = useCallback((newQuery: string): void => {
|
||||
try {
|
||||
const validationResponse = validateQuery(newExpression);
|
||||
const validationResponse = validateQuery(newQuery);
|
||||
setValidation(validationResponse);
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
@@ -127,7 +127,7 @@ function QuerySearch({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCurrentExpression = useCallback(
|
||||
const getCurrentQuery = useCallback(
|
||||
(): string => editorRef.current?.state.doc.toString() || '',
|
||||
[],
|
||||
);
|
||||
@@ -167,14 +167,19 @@ function QuerySearch({
|
||||
() => {
|
||||
if (!isEditorReady) return;
|
||||
|
||||
const newExpression = queryData.filter?.expression || '';
|
||||
const currentExpression = getCurrentExpression();
|
||||
const newQuery = queryData.filter?.expression || '';
|
||||
const currentQuery = getCurrentQuery();
|
||||
|
||||
// Do not update codemirror editor if the expression is the same
|
||||
if (newExpression !== currentExpression && !isFocused) {
|
||||
updateEditorValue(newExpression, { skipOnChange: true });
|
||||
if (newExpression) {
|
||||
handleQueryValidation(newExpression);
|
||||
/* eslint-disable-next-line sonarjs/no-collapsible-if */
|
||||
if (newQuery !== currentQuery && !isFocused) {
|
||||
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
|
||||
// Only update if newQuery has a value, or if both are empty (initial state)
|
||||
if (newQuery || !currentQuery) {
|
||||
updateEditorValue(newQuery, { skipOnChange: true });
|
||||
|
||||
if (newQuery) {
|
||||
handleQueryValidation(newQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -608,8 +613,8 @@ function QuerySearch({
|
||||
};
|
||||
|
||||
const handleBlur = (): void => {
|
||||
const currentExpression = getCurrentExpression();
|
||||
handleQueryValidation(currentExpression);
|
||||
const currentQuery = getCurrentQuery();
|
||||
handleQueryValidation(currentQuery);
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
@@ -628,11 +633,11 @@ function QuerySearch({
|
||||
|
||||
const handleExampleClick = (exampleQuery: string): void => {
|
||||
// If there's an existing query, append the example with AND
|
||||
const currentExpression = getCurrentExpression();
|
||||
const newExpression = currentExpression
|
||||
? `${currentExpression} AND ${exampleQuery}`
|
||||
const currentQuery = getCurrentQuery();
|
||||
const newQuery = currentQuery
|
||||
? `${currentQuery} AND ${exampleQuery}`
|
||||
: exampleQuery;
|
||||
updateEditorValue(newExpression);
|
||||
updateEditorValue(newQuery);
|
||||
};
|
||||
|
||||
// Helper function to render a badge for the current context mode
|
||||
@@ -668,9 +673,9 @@ function QuerySearch({
|
||||
if (word?.from === word?.to && !context.explicit) return null;
|
||||
|
||||
// Get current query from editor
|
||||
const currentExpression = getCurrentExpression();
|
||||
const currentQuery = editorRef.current?.state.doc.toString() || '';
|
||||
// Get the query context at the cursor position
|
||||
const queryContext = getQueryContextAtCursor(currentExpression, cursorPos.ch);
|
||||
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
|
||||
|
||||
// Define autocomplete options based on the context
|
||||
let options: {
|
||||
@@ -1166,8 +1171,8 @@ function QuerySearch({
|
||||
|
||||
if (queryContext.isInParenthesis) {
|
||||
// Different suggestions based on the context within parenthesis or bracket
|
||||
const currentExpression = getCurrentExpression();
|
||||
const curChar = currentExpression.charAt(cursorPos.ch - 1) || '';
|
||||
const currentQuery = editorRef.current?.state.doc.toString() || '';
|
||||
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
|
||||
|
||||
if (curChar === '(' || curChar === '[') {
|
||||
// Right after opening parenthesis/bracket
|
||||
@@ -1316,7 +1321,7 @@ function QuerySearch({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
|
||||
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
@@ -1378,7 +1383,7 @@ function QuerySearch({
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(getCurrentExpression());
|
||||
onRun(getCurrentQuery());
|
||||
} else {
|
||||
handleRunQuery();
|
||||
}
|
||||
@@ -1404,7 +1409,7 @@ function QuerySearch({
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{getCurrentExpression() && validation.isValid === false && !isFocused && (
|
||||
{getCurrentQuery() && validation.isValid === false && !isFocused && (
|
||||
<div
|
||||
className={cx('query-status-container', {
|
||||
hasErrors: validation.errors.length > 0,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable import/named */
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
@@ -152,6 +151,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
>;
|
||||
mockedGetKeys.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
@@ -170,8 +171,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
|
||||
// Focus and type into the editor
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, SAMPLE_KEY_TYPING);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
||||
|
||||
// Wait for debounced API call (300ms debounce + some buffer)
|
||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||
@@ -186,6 +187,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
>;
|
||||
mockedGetValues.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
@@ -201,8 +204,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
|
||||
// Wait for debounced API call (300ms debounce + some buffer)
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
@@ -238,6 +241,7 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
|
||||
it('calls provided onRun on Mod-Enter', async () => {
|
||||
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -255,8 +259,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, SAMPLE_STATUS_QUERY);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_STATUS_QUERY);
|
||||
|
||||
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||
@@ -276,6 +280,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
>;
|
||||
mockedHandleRunQuery.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
@@ -291,8 +297,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
|
||||
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||
@@ -342,73 +348,4 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('handles queryData.filter.expression changes without triggering onChange', async () => {
|
||||
// Spy on CodeMirror's EditorView.dispatch, which is invoked when updateEditorValue
|
||||
// applies a programmatic change to the editor.
|
||||
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
|
||||
const initialExpression = "service.name = 'frontend'";
|
||||
const updatedExpression = "service.name = 'backend'";
|
||||
|
||||
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
|
||||
|
||||
const initialQueryData = {
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filter: {
|
||||
expression: initialExpression,
|
||||
},
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={initialQueryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for CodeMirror to initialize with the initial expression
|
||||
await waitFor(
|
||||
() => {
|
||||
const editorContent = document.querySelector(
|
||||
CM_EDITOR_SELECTOR,
|
||||
) as HTMLElement;
|
||||
expect(editorContent).toBeInTheDocument();
|
||||
const textContent = editorContent.textContent || '';
|
||||
expect(textContent).toBe(initialExpression);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
// Ensure the editor is explicitly blurred (not focused)
|
||||
// Blur the actual CodeMirror editor container so that QuerySearch's onBlur handler runs.
|
||||
// Note: In jsdom + CodeMirror we can't reliably assert the DOM text content changes when
|
||||
// the expression is updated programmatically, but we can assert that:
|
||||
// 1) The component continues to render, and
|
||||
// 2) No onChange is fired for programmatic updates.
|
||||
|
||||
const updatedQueryData = {
|
||||
...initialQueryData,
|
||||
filter: {
|
||||
expression: updatedExpression,
|
||||
},
|
||||
};
|
||||
|
||||
// Re-render with updated queryData.filter.expression
|
||||
rerender(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={updatedQueryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// updateEditorValue should have resulted in a dispatch call + onChange should not have been called
|
||||
await waitFor(() => {
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/* eslint-disable */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -61,7 +55,16 @@ jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// ReduceToFilter is not mocked - we test the actual Ant Design Select component
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
|
||||
() => ({
|
||||
ReduceToFilter: ({ onChange }: any) => (
|
||||
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
|
||||
ReduceToFilter
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function baseQuery(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
@@ -137,7 +140,7 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', async () => {
|
||||
it('limit input auto-opens when limit is set and changing it calls handler', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
@@ -180,88 +183,4 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(limitInput.value).toBe('7');
|
||||
});
|
||||
|
||||
it('shows reduce-to add-on when showReduceTo is true', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('query-add-on-reduce_to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-opens reduce-to content when reduceTo is set', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: 'sum' })}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleSetQueryData when reduce-to value changes', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const query = baseQuery({
|
||||
reduceTo: 'avg',
|
||||
aggregations: [{ id: 'a', operator: 'count', reduceTo: 'avg' }],
|
||||
});
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for the reduce-to content section to be visible (it auto-opens when reduceTo is set)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the Select component by its role (combobox)
|
||||
// The Select is within the reduce-to-content section
|
||||
const reduceToContent = screen.getByTestId('reduce-to-content');
|
||||
const selectCombobox = within(reduceToContent).getByRole('combobox');
|
||||
|
||||
// Open the dropdown by clicking on the combobox
|
||||
await user.click(selectCombobox);
|
||||
|
||||
// Wait for the dropdown listbox to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Find and click the "Sum" option
|
||||
const sumOption = await screen.findByText('Sum of values in timeframe');
|
||||
await user.click(sumOption);
|
||||
|
||||
// Verify the handler was called with the correct value
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(0, {
|
||||
...query,
|
||||
aggregations: [
|
||||
{
|
||||
...(query.aggregations?.[0] as any),
|
||||
reduceTo: 'sum',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,10 +163,6 @@ function formatSingleValueForFilter(
|
||||
if (trimmed === 'true' || trimmed === 'false') {
|
||||
return trimmed === 'true';
|
||||
}
|
||||
|
||||
if (isQuoted(value)) {
|
||||
return unquote(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Return non-string values as-is, or string values that couldn't be converted
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -6,7 +5,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
|
||||
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -42,15 +41,13 @@ interface MockFilterConfig {
|
||||
type: FiltersType;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY = 'service.name';
|
||||
|
||||
const createMockFilter = (
|
||||
overrides: Partial<MockFilterConfig> = {},
|
||||
): MockFilterConfig => ({
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
title: 'Service Name',
|
||||
attributeKey: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
@@ -71,7 +68,7 @@ const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
|
||||
? [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
@@ -191,222 +188,4 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update query filters when a checkbox is clicked', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Start with no active filters so clicking a checkbox creates one
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
...createMockQueryBuilderData(false),
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for checkboxes to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// User unchecks the first value (`mq-kafka`)
|
||||
await userEvent.click(checkboxes[0]);
|
||||
|
||||
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
expect(updatedFilters.items).toHaveLength(1);
|
||||
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
|
||||
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
|
||||
expect(updatedFilters.items[0].op).toBe('not in');
|
||||
expect(updatedFilters.items[0].value).toBe('mq-kafka');
|
||||
});
|
||||
|
||||
it('should set an IN filter with only the clicked value when using Only', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['mq-kafka', 'otel-demo'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for values to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the value label to trigger the "Only" behavior
|
||||
await userEvent.click(screen.getByText('mq-kafka'));
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
expect(updatedFilters.items).toHaveLength(1);
|
||||
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
|
||||
expect(updatedFilters.items[0].op).toBe('in');
|
||||
expect(updatedFilters.items[0].value).toBe('mq-kafka');
|
||||
});
|
||||
|
||||
it('should clear filters for the attribute when using All', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN ['mq-kafka']
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['mq-kafka'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
|
||||
await userEvent.click(screen.getByText('mq-kafka'));
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
|
||||
const filtersForServiceName = updatedFilters.items.filter(
|
||||
(item: any) => item.key?.key === SERVICE_NAME_KEY,
|
||||
);
|
||||
|
||||
expect(filtersForServiceName).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should extend an existing IN filter when checking an additional value', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
|
||||
// Existing filter: service.name IN 'mq-kafka'
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: SERVICE_NAME_KEY,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: 'mq-kafka',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: true });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for checkboxes to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// First checkbox corresponds to 'mq-kafka' (already selected),
|
||||
// second will be 'otel-demo' which we now select additionally.
|
||||
await userEvent.click(checkboxes[1]);
|
||||
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
|
||||
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
const updatedFilters = updatedQuery.builder.queryData[0].filters;
|
||||
const [filterForServiceName] = updatedFilters.items;
|
||||
|
||||
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
|
||||
expect(filterForServiceName.op).toBe('in');
|
||||
expect(filterForServiceName.value).toEqual(['mq-kafka', 'otel-demo']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
|
||||
import { Table } from 'antd';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Table, Tooltip } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ColumnTitleIcon,
|
||||
ColumnTitleWrapper,
|
||||
} from 'container/OptionsMenu/styles';
|
||||
import { dragColumnParams } from 'hooks/useDragColumns/configs';
|
||||
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { debounce, set } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
import React, {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -71,20 +76,48 @@ function ResizeTable({
|
||||
|
||||
const mergedColumns = useMemo(
|
||||
() =>
|
||||
columnsData.map((col, index) => ({
|
||||
...col,
|
||||
...(onDragColumn && {
|
||||
title: (
|
||||
<DragSpanStyle className="dragHandler">
|
||||
{col?.title?.toString() || ''}
|
||||
</DragSpanStyle>
|
||||
),
|
||||
}),
|
||||
onHeaderCell: (column: ColumnsType<unknown>[number]): unknown => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(index),
|
||||
}),
|
||||
})) as ColumnsType<any>,
|
||||
columnsData.map((col, index) => {
|
||||
const columnRecord = col as Record<string, unknown>;
|
||||
const hasUnselectedConflict = columnRecord._hasUnselectedConflict === true;
|
||||
const titleText = col?.title?.toString();
|
||||
|
||||
// Render tooltip icon when there's a conflict, regardless of drag functionality
|
||||
// Only wrap in DragSpanStyle when drag is enabled
|
||||
const tooltipIcon = hasUnselectedConflict ? (
|
||||
<Tooltip title="The same column with a different type or context exists">
|
||||
<ColumnTitleIcon>
|
||||
<InfoCircleOutlined />
|
||||
</ColumnTitleIcon>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const titleWithWrapper = (
|
||||
<ColumnTitleWrapper>
|
||||
{titleText}
|
||||
{tooltipIcon}
|
||||
</ColumnTitleWrapper>
|
||||
);
|
||||
|
||||
let titleElement: React.ReactNode = titleText;
|
||||
if (hasUnselectedConflict || onDragColumn) {
|
||||
if (onDragColumn) {
|
||||
titleElement = (
|
||||
<DragSpanStyle className="dragHandler">{titleWithWrapper}</DragSpanStyle>
|
||||
);
|
||||
} else {
|
||||
titleElement = titleWithWrapper;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...col,
|
||||
title: titleElement,
|
||||
onHeaderCell: (column: ColumnsType<unknown>[number]): unknown => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(index),
|
||||
}),
|
||||
};
|
||||
}) as ColumnsType<RowData>,
|
||||
[columnsData, onDragColumn, handleResize],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { ShiftOverlay } from './ShiftOverlay';
|
||||
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function ShiftHoldOverlayController({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
}): JSX.Element | null {
|
||||
const { open: isCmdKOpen } = useCmdK();
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
handleThemeChange: noop,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
isModalOpen: isCmdKOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<ShiftOverlay visible={visible} actions={actions} userRole={userRole} />
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import './shiftOverlay.scss';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { formatShortcut } from './formatShortcut';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
interface ShortcutProps {
|
||||
label: string;
|
||||
keyHint: React.ReactNode;
|
||||
}
|
||||
|
||||
function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
|
||||
return (
|
||||
<div className="shift-overlay__item">
|
||||
<span className="shift-overlay__label">{label}</span>
|
||||
<kbd className="shift-overlay__kbd">{keyHint}</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShiftOverlayProps {
|
||||
visible: boolean;
|
||||
actions: CmdAction[];
|
||||
userRole: UserRole;
|
||||
}
|
||||
|
||||
export function ShiftOverlay({
|
||||
visible,
|
||||
actions,
|
||||
userRole,
|
||||
}: ShiftOverlayProps): JSX.Element | null {
|
||||
const navigationActions = useMemo(() => {
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
(a) => !a.roles || a.roles.includes(userRole),
|
||||
);
|
||||
|
||||
// Navigation only + must have shortcut
|
||||
return permitted.filter(
|
||||
(a) =>
|
||||
a.section?.toLowerCase() === 'navigation' &&
|
||||
a.shortcut &&
|
||||
a.shortcut.length > 0,
|
||||
);
|
||||
}, [actions, userRole]);
|
||||
|
||||
if (!visible || navigationActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="shift-overlay">
|
||||
<div className="shift-overlay__panel">
|
||||
{navigationActions.map((action) => (
|
||||
<Shortcut
|
||||
key={action.id}
|
||||
label={action.name.replace(/^Go to\s+/i, '')}
|
||||
keyHint={formatShortcut(action.shortcut)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import type { CmdAction } from '../ShiftOverlay';
|
||||
import { ShiftOverlay } from '../ShiftOverlay';
|
||||
|
||||
jest.mock('../formatShortcut', () => ({
|
||||
formatShortcut: (shortcut: string[]): string => shortcut.join('+'),
|
||||
}));
|
||||
|
||||
const baseActions: CmdAction[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Go to Traces',
|
||||
section: 'navigation',
|
||||
shortcut: ['Shift', 'T'],
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Go to Metrics',
|
||||
section: 'navigation',
|
||||
shortcut: ['Shift', 'M'],
|
||||
roles: ['ADMIN'], // ✅ now UserRole[]
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Create Alert',
|
||||
section: 'actions',
|
||||
shortcut: ['A'],
|
||||
perform: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Go to Logs',
|
||||
section: 'navigation',
|
||||
perform: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
describe('ShiftOverlay', () => {
|
||||
it('renders nothing when not visible', () => {
|
||||
const { container } = render(
|
||||
<ShiftOverlay visible={false} actions={baseActions} userRole="ADMIN" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when no navigation shortcuts exist', () => {
|
||||
const { container } = render(
|
||||
<ShiftOverlay
|
||||
visible
|
||||
actions={[
|
||||
{
|
||||
id: 'x',
|
||||
name: 'Create Alert',
|
||||
section: 'actions',
|
||||
perform: jest.fn(),
|
||||
},
|
||||
]}
|
||||
userRole="ADMIN"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders navigation shortcuts in a portal', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(document.body.querySelector('.shift-overlay')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metrics')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Shift+T')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shift+M')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies RBAC filtering correctly', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="VIEWER" />);
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('strips "Go to" prefix from labels', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Traces')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Go to Traces')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render actions without shortcuts', () => {
|
||||
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
|
||||
|
||||
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useShiftHoldOverlay } from '../useShiftHoldOverlay';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
function pressShift(target: EventTarget = window): void {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Shift',
|
||||
bubbles: true,
|
||||
});
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function releaseShift(): void {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keyup', {
|
||||
key: 'Shift',
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('useShiftHoldOverlay', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
it('shows overlay after holding Shift for 600ms', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show overlay if Shift is released early', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(300);
|
||||
releaseShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('hides overlay on Shift key release', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
releaseShift();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does not activate when modal is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useShiftHoldOverlay({ isModalOpen: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does not activate in typing context (input)', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift(input);
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('cleans up on window blur', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up on document visibility change', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({}));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const { result } = renderHook(() => useShiftHoldOverlay({ disabled: true }));
|
||||
|
||||
act(() => {
|
||||
pressShift();
|
||||
jest.advanceTimersByTime(600);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import './shiftOverlay.scss';
|
||||
|
||||
import { ArrowUp, ChevronUp, Command, Option } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function formatShortcut(shortcut?: string[]): ReactNode {
|
||||
if (!shortcut || shortcut.length === 0) return null;
|
||||
|
||||
const combo = shortcut.find((s) => typeof s === 'string' && s.trim());
|
||||
if (!combo) return null;
|
||||
|
||||
return combo.split('+').map((key) => {
|
||||
const k = key.trim().toLowerCase();
|
||||
|
||||
let node: ReactNode;
|
||||
switch (k) {
|
||||
case 'shift':
|
||||
node = <ArrowUp size={14} />;
|
||||
break;
|
||||
case 'cmd':
|
||||
case 'meta':
|
||||
node = <Command size={14} />;
|
||||
break;
|
||||
case 'alt':
|
||||
node = <Option size={14} />;
|
||||
break;
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
node = <ChevronUp size={14} />;
|
||||
break;
|
||||
case 'arrowup':
|
||||
node = <ArrowUp size={14} />;
|
||||
break;
|
||||
default:
|
||||
node = k.toUpperCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`shortcut-${k}`} className="shift-overlay__key">
|
||||
{node}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
.shift-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
|
||||
&__panel {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 8px 12px;
|
||||
|
||||
background: var(--bg-ink-500);
|
||||
color: var(--bg-vanilla-300);
|
||||
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
|
||||
box-shadow: 0 6px 20px var(--bg-ink-500);
|
||||
animation: shift-overlay-fade-in 120ms ease-out;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__label {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__kbd {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
display: flex;
|
||||
|
||||
border-radius: 4px;
|
||||
background: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
&__key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 15px;
|
||||
height: 20px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
background-color: var(--bg-slate-100);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--bg-vanilla-300);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shift-overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const HOLD_DELAY_MS = 500;
|
||||
|
||||
function isTypingContext(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
|
||||
}
|
||||
|
||||
interface UseShiftHoldOverlayOptions {
|
||||
disabled?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useShiftHoldOverlay({
|
||||
disabled = false,
|
||||
isModalOpen = false,
|
||||
}: UseShiftHoldOverlayOptions): boolean {
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const isHoldingRef = useRef<boolean>(false);
|
||||
|
||||
useEffect((): (() => void) | void => {
|
||||
if (disabled) return;
|
||||
|
||||
function cleanup(): void {
|
||||
isHoldingRef.current = false;
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Shift') return;
|
||||
if (e.repeat) return;
|
||||
|
||||
// Suppress in bad contexts
|
||||
if (
|
||||
isModalOpen ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey ||
|
||||
isTypingContext(e.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isHoldingRef.current = true;
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (isHoldingRef.current) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, HOLD_DELAY_MS);
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Shift') return;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onBlur(): void {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', cleanup);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', cleanup);
|
||||
};
|
||||
}, [disabled, isModalOpen]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import ValueGraph from '../index';
|
||||
import { getBackgroundColorAndThresholdCheck } from '../utils';
|
||||
|
||||
// Mock the utils module
|
||||
jest.mock('../utils', () => ({
|
||||
getBackgroundColorAndThresholdCheck: jest.fn(() => ({
|
||||
threshold: {} as any,
|
||||
isConflictingThresholds: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockGetBackgroundColorAndThresholdCheck = getBackgroundColorAndThresholdCheck as jest.MockedFunction<
|
||||
typeof getBackgroundColorAndThresholdCheck
|
||||
>;
|
||||
|
||||
const TEST_ID_VALUE_GRAPH_TEXT = 'value-graph-text';
|
||||
const TEST_ID_VALUE_GRAPH_PREFIX_UNIT = 'value-graph-prefix-unit';
|
||||
const TEST_ID_VALUE_GRAPH_SUFFIX_UNIT = 'value-graph-suffix-unit';
|
||||
|
||||
describe('ValueGraph', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the numeric value correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
|
||||
});
|
||||
|
||||
it('renders value with suffix unit', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="42ms" rawValue={42} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('ms');
|
||||
});
|
||||
|
||||
it('renders value with prefix unit', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="$100" rawValue={100} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
|
||||
});
|
||||
|
||||
it('renders value with both prefix and suffix units', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="$100USD" rawValue={100} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('USD');
|
||||
});
|
||||
|
||||
it('renders value with K suffix', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1.5K" rawValue={1500} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.5K');
|
||||
});
|
||||
|
||||
it('applies text color when threshold format is Text', () => {
|
||||
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
|
||||
threshold: {
|
||||
thresholdFormat: 'Text',
|
||||
thresholdColor: 'red',
|
||||
} as any,
|
||||
isConflictingThresholds: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveStyle({ color: 'red' });
|
||||
});
|
||||
|
||||
it('applies background color when threshold format is Background', () => {
|
||||
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
|
||||
threshold: {
|
||||
thresholdFormat: 'Background',
|
||||
thresholdColor: 'blue',
|
||||
} as any,
|
||||
isConflictingThresholds: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
|
||||
);
|
||||
|
||||
const containerElement = container.querySelector('.value-graph-container');
|
||||
expect(containerElement).toHaveStyle({ backgroundColor: 'blue' });
|
||||
});
|
||||
|
||||
it('displays conflicting thresholds indicator when multiple thresholds match', () => {
|
||||
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
|
||||
threshold: {
|
||||
thresholdFormat: 'Text',
|
||||
thresholdColor: 'red',
|
||||
} as any,
|
||||
isConflictingThresholds: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display conflicting thresholds indicator when no conflict', () => {
|
||||
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
|
||||
threshold: {} as any,
|
||||
isConflictingThresholds: false,
|
||||
});
|
||||
|
||||
render(<ValueGraph value="42" rawValue={42} thresholds={[]} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('conflicting-thresholds'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies text color to units when threshold format is Text', () => {
|
||||
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
|
||||
threshold: {
|
||||
thresholdFormat: 'Text',
|
||||
thresholdColor: 'green',
|
||||
} as any,
|
||||
isConflictingThresholds: false,
|
||||
});
|
||||
|
||||
render(<ValueGraph value="42ms" rawValue={42} thresholds={[]} />);
|
||||
|
||||
const unitElement = screen.getByText('ms');
|
||||
expect(unitElement).toHaveStyle({ color: 'green' });
|
||||
});
|
||||
|
||||
it('renders decimal values correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="42.5" rawValue={42.5} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42.5');
|
||||
});
|
||||
|
||||
it('handles values with M suffix', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1.2M" rawValue={1200000} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.2M');
|
||||
});
|
||||
|
||||
it('handles values with B suffix', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="2.3B" rawValue={2300000000} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('2.3B');
|
||||
});
|
||||
|
||||
it('handles scientific notation values', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1e-9" rawValue={1e-9} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
|
||||
});
|
||||
|
||||
it('handles scientific notation with suffix unit', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1e-9%" rawValue={1e-9} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('%');
|
||||
});
|
||||
|
||||
it('handles scientific notation with uppercase E', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1E-9" rawValue={1e-9} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1E-9');
|
||||
});
|
||||
|
||||
it('handles scientific notation with positive exponent', () => {
|
||||
const { getByTestId } = render(
|
||||
<ValueGraph value="1e+9" rawValue={1e9} thresholds={[]} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e+9');
|
||||
});
|
||||
});
|
||||
@@ -3,39 +3,11 @@ import './ValueGraph.styles.scss';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getBackgroundColorAndThresholdCheck } from './utils';
|
||||
|
||||
function Unit({
|
||||
type,
|
||||
unit,
|
||||
threshold,
|
||||
fontSize,
|
||||
}: {
|
||||
type: 'prefix' | 'suffix';
|
||||
unit: string;
|
||||
threshold: ThresholdProps;
|
||||
fontSize: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Typography.Text
|
||||
className="value-graph-unit"
|
||||
data-testid={`value-graph-${type}-unit`}
|
||||
style={{
|
||||
color:
|
||||
threshold.thresholdFormat === 'Text'
|
||||
? threshold.thresholdColor
|
||||
: undefined,
|
||||
fontSize: `calc(${fontSize} * 0.7)`,
|
||||
}}
|
||||
>
|
||||
{unit}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueGraph({
|
||||
value,
|
||||
rawValue,
|
||||
@@ -45,16 +17,10 @@ function ValueGraph({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState('2.5vw');
|
||||
|
||||
const { numericValue, prefixUnit, suffixUnit } = useMemo(() => {
|
||||
const matches = value.match(
|
||||
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
|
||||
);
|
||||
return {
|
||||
numericValue: matches?.[2] || value,
|
||||
prefixUnit: matches?.[1]?.trim() || '',
|
||||
suffixUnit: matches?.[3]?.trim() || '',
|
||||
};
|
||||
}, [value]);
|
||||
// Parse value to separate number and unit (assuming unit is at the end)
|
||||
const matches = value.match(/([\d.]+[KMB]?)(.*)$/);
|
||||
const numericValue = matches?.[1] || value;
|
||||
const unit = matches?.[2]?.trim() || '';
|
||||
|
||||
// Adjust font size based on container size
|
||||
useEffect(() => {
|
||||
@@ -99,17 +65,8 @@ function ValueGraph({
|
||||
}}
|
||||
>
|
||||
<div className="value-text-container">
|
||||
{prefixUnit && (
|
||||
<Unit
|
||||
type="prefix"
|
||||
unit={prefixUnit}
|
||||
threshold={threshold}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text
|
||||
className="value-graph-text"
|
||||
data-testid="value-graph-text"
|
||||
style={{
|
||||
color:
|
||||
threshold.thresholdFormat === 'Text'
|
||||
@@ -120,13 +77,19 @@ function ValueGraph({
|
||||
>
|
||||
{numericValue}
|
||||
</Typography.Text>
|
||||
{suffixUnit && (
|
||||
<Unit
|
||||
type="suffix"
|
||||
unit={suffixUnit}
|
||||
threshold={threshold}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
{unit && (
|
||||
<Typography.Text
|
||||
className="value-graph-unit"
|
||||
style={{
|
||||
color:
|
||||
threshold.thresholdFormat === 'Text'
|
||||
? threshold.thresholdColor
|
||||
: undefined,
|
||||
fontSize: `calc(${fontSize} * 0.7)`,
|
||||
}}
|
||||
>
|
||||
{unit}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{isConflictingThresholds && (
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
|
||||
*/
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
// ---- Mocks (must run BEFORE importing the component) ----
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { CmdKPalette } from '../cmdKPalette';
|
||||
|
||||
const HOME_LABEL = 'Go to Home';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// restore
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete (HTMLElement.prototype as any).scrollIntoView;
|
||||
});
|
||||
|
||||
// mock history.push / replace / go / location
|
||||
jest.mock('lib/history', () => {
|
||||
const location = { pathname: '/', search: '', hash: '' };
|
||||
|
||||
const stack: { pathname: string; search: string }[] = [
|
||||
{ pathname: '/', search: '' },
|
||||
];
|
||||
|
||||
const push = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
stack.push({ pathname, search });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replace = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1] = { pathname, search };
|
||||
} else {
|
||||
stack.push({ pathname, search });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const listen = jest.fn();
|
||||
const go = jest.fn((n: number) => {
|
||||
if (n < 0 && stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
|
||||
location.pathname = top.pathname;
|
||||
location.search = top.search;
|
||||
});
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
location,
|
||||
__stack: stack,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ResizeObserver for Jest/jsdom
|
||||
class ResizeObserver {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
observe() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
unobserve() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
(global as any).ResizeObserver = ResizeObserver;
|
||||
|
||||
// mock cmdK provider hook (open state + setter)
|
||||
const mockSetOpen = jest.fn();
|
||||
jest.mock('providers/cmdKProvider', (): unknown => ({
|
||||
useCmdK: (): {
|
||||
open: boolean;
|
||||
setOpen: jest.Mock;
|
||||
openCmdK: jest.Mock;
|
||||
closeCmdK: jest.Mock;
|
||||
} => ({
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
openCmdK: jest.fn(),
|
||||
closeCmdK: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock notifications hook
|
||||
jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
theme: string;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
jest.mock('react-query', (): unknown => {
|
||||
const actual = jest.requireActual('react-query');
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
// ---- Tests ----
|
||||
describe('CmdKPalette', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders navigation and settings groups and items', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a navigation item calls history.push with correct route', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const homeItem = screen.getByText(HOME_LABEL);
|
||||
await userEvent.click(homeItem);
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
|
||||
test('role-based filtering (basic smoke)', () => {
|
||||
render(<CmdKPalette userRole="VIEWER" />);
|
||||
|
||||
// VIEWER still sees basic navigation items
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keyboard shortcut opens palette via setOpen', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('items render with icons when provided', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const iconHolders = document.querySelectorAll('.cmd-item-icon');
|
||||
expect(iconHolders.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closing the palette via handleInvoke sets open to false', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const dashItem = screen.getByText('Go to Dashboards');
|
||||
await userEvent.click(dashItem);
|
||||
|
||||
// last call from handleInvoke should set open to false
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.cmdk-section-heading [cmdk-group-heading] {
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scroll */
|
||||
.cmdk-list-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cmdk-input-wrapper {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cmdk-item-light:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
|
||||
.cmdk-item-light[data-selected='true'] {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.cmdk-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[cmdk-item] svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cmd-item-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@signozhq/command';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function CmdKPalette({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
|
||||
// toggle palette with ⌘/Ctrl+K
|
||||
function handleGlobalCmdK(
|
||||
e: KeyboardEvent,
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
const cmdKEffect = (): void | (() => void) => {
|
||||
const listener = (e: KeyboardEvent): void => {
|
||||
handleGlobalCmdK(e, setOpen);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', listener);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
setOpen(false);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
history.push(key);
|
||||
}
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: onClickHandler,
|
||||
handleThemeChange,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
(a) => !a.roles || a.roles.includes(userRole),
|
||||
);
|
||||
|
||||
// group permitted actions by section
|
||||
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
|
||||
const map = new Map<string, CmdAction[]>();
|
||||
|
||||
permitted.forEach((a) => {
|
||||
const section = a.section ?? 'Other';
|
||||
const existing = map.get(section);
|
||||
|
||||
if (existing) {
|
||||
existing.push(a);
|
||||
} else {
|
||||
map.set(section, [a]);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.entries());
|
||||
})();
|
||||
|
||||
const handleInvoke = (action: CmdAction): void => {
|
||||
try {
|
||||
action.perform();
|
||||
} catch (e) {
|
||||
console.error('Error invoking action', e);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span className="cmd-item-icon">{it.icon}</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart2,
|
||||
BellDot,
|
||||
BugIcon,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
Expand,
|
||||
HardDrive,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange } = deps;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: [GlobalShortcutsName.NavigateToHome],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
icon: <Home size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.HOME),
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: [GlobalShortcutsName.NavigateToDashboards],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
icon: <LayoutGrid size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.ALL_DASHBOARD),
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: [GlobalShortcutsName.NavigateToServices],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
icon: <HardDrive size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.APPLICATION),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: [GlobalShortcutsName.NavigateToAlerts],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
icon: <BellDot size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LIST_ALL_ALERT),
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: [GlobalShortcutsName.NavigateToExceptions],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
icon: <BugIcon size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.ALL_ERROR),
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMessagingQueues],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
icon: <ListMinus size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.MESSAGING_QUEUES_OVERVIEW),
|
||||
},
|
||||
|
||||
// logs
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogs],
|
||||
keywords: 'logs',
|
||||
section: 'Logs',
|
||||
icon: <ScrollText size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Pipelines',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsPipelines],
|
||||
keywords: 'logs pipelines',
|
||||
section: 'Logs',
|
||||
icon: <Workflow size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
|
||||
keywords: 'logs views',
|
||||
section: 'Logs',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
|
||||
},
|
||||
|
||||
// metrics
|
||||
{
|
||||
id: 'metrics-summary',
|
||||
name: 'Go to Metrics Summary',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsSummary],
|
||||
keywords: 'metrics summary',
|
||||
section: 'Metrics',
|
||||
icon: <BarChart2 size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-explorer',
|
||||
name: 'Go to Metrics Explorer',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsExplorer],
|
||||
keywords: 'metrics explorer',
|
||||
section: 'Metrics',
|
||||
icon: <Compass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-views',
|
||||
name: 'Go to Metrics Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
|
||||
keywords: 'metrics views',
|
||||
section: 'Metrics',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
|
||||
},
|
||||
|
||||
// Traces
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: [GlobalShortcutsName.NavigateToTraces],
|
||||
keywords: 'traces',
|
||||
section: 'Traces',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.TRACES_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'traces-funnel',
|
||||
name: 'Go to Traces Funnels',
|
||||
shortcut: [GlobalShortcutsName.NavigateToTracesFunnel],
|
||||
keywords: 'traces funnel',
|
||||
section: 'Traces',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.TRACES_FUNNELS),
|
||||
},
|
||||
|
||||
// Common actions
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.DARK),
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Common',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
|
||||
},
|
||||
|
||||
// settings sub-pages
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettings],
|
||||
keywords: 'account settings',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.MY_SETTINGS),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-ingestion',
|
||||
name: 'Go to Account Settings Ingestion',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsIngestion],
|
||||
keywords: 'account settings',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.INGESTION_SETTINGS),
|
||||
},
|
||||
|
||||
{
|
||||
id: 'my-settings-billing',
|
||||
name: 'Go to Account Settings Billing',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsBilling],
|
||||
keywords: 'account settings billing',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.BILLING),
|
||||
},
|
||||
{
|
||||
id: 'my-settings-api-keys',
|
||||
name: 'Go to Account Settings API Keys',
|
||||
shortcut: [GlobalShortcutsName.NavigateToSettingsAPIKeys],
|
||||
keywords: 'account settings api keys',
|
||||
section: 'Settings',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR'],
|
||||
perform: (): void => navigate(ROUTES.API_KEYS),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,57 +1,25 @@
|
||||
export const GlobalShortcuts = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
|
||||
// logs
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
|
||||
// traces
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
|
||||
// metrics
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
|
||||
// settings
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToServices: 's+shift',
|
||||
NavigateToTraces: 't+shift',
|
||||
NavigateToLogs: 'l+shift',
|
||||
NavigateToDashboards: 'd+shift',
|
||||
NavigateToAlerts: 'a+shift',
|
||||
NavigateToExceptions: 'e+shift',
|
||||
NavigateToMessagingQueues: 'm+shift',
|
||||
ToggleSidebar: 'b+shift',
|
||||
NavigateToHome: 'h+shift',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsName = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
NavigateToMessagingQueues: 'shift+m',
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsAPIKeys: 'shift+g+k',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -64,17 +32,4 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToExceptions: 'Navigate to Exceptions List',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
|
||||
NavigateToTracesViews: 'Navigate to Traces Views',
|
||||
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
|
||||
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
|
||||
NavigateToMetricsViews: 'Navigate to Metrics Views',
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsAPIKeys: 'Navigate to API Keys Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToLogsViews: 'Navigate to Logs Views',
|
||||
};
|
||||
|
||||
@@ -10,20 +10,6 @@ import {
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('providers/cmdKProvider', () => ({
|
||||
useCmdK: (): {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
openCmdK: () => void;
|
||||
closeCmdK: () => void;
|
||||
} => ({
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
openCmdK: jest.fn(),
|
||||
closeCmdK: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
@@ -77,7 +63,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
describe('Global Shortcuts Constants', () => {
|
||||
it('should have the correct shortcut key combination', () => {
|
||||
expect(GlobalShortcuts.ToggleSidebar).toBe('shift+b');
|
||||
expect(GlobalShortcuts.ToggleSidebar).toBe('b+shift');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import { useKBar } from 'kbar';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -185,6 +186,19 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const { query, disabled } = useKBar((state) => ({
|
||||
disabled: state.disabled,
|
||||
}));
|
||||
|
||||
// disable the kbar command palette when not logged in
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
query.disable(false);
|
||||
} else {
|
||||
query.disable(true);
|
||||
}
|
||||
}, [isLoggedIn, query, disabled]);
|
||||
|
||||
const changelogForTenant = isCloudUserVal
|
||||
? DeploymentType.CLOUD_ONLY
|
||||
: DeploymentType.OSS_ONLY;
|
||||
|
||||
@@ -393,21 +393,15 @@ function ExplorerOptions({
|
||||
backwardCompatibleOptions = omit(options, 'version');
|
||||
}
|
||||
|
||||
// Use the correct default columns based on the current data source
|
||||
const defaultColumns =
|
||||
sourcepage === DataSource.TRACES
|
||||
? defaultTraceSelectedColumns
|
||||
: defaultLogsSelectedColumns;
|
||||
|
||||
if (extraData.selectColumns?.length) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: extraData.selectColumns,
|
||||
});
|
||||
} else if (!isEqual(defaultColumns, options.selectColumns)) {
|
||||
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: defaultColumns,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,6 +67,7 @@ function WidgetGraphComponent({
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { notifications } = useNotifications();
|
||||
const { pathname, search } = useLocation();
|
||||
|
||||
@@ -315,6 +316,18 @@ function WidgetGraphComponent({
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
onMouseOver={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseOut={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
onBlur={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
id={widget.id}
|
||||
className="widget-graph-component-container"
|
||||
>
|
||||
@@ -364,6 +377,7 @@ function WidgetGraphComponent({
|
||||
|
||||
<div className="drag-handle">
|
||||
<WidgetHeader
|
||||
parentHover={hovered}
|
||||
title={widget?.title}
|
||||
widget={widget}
|
||||
onView={handleOnView}
|
||||
|
||||
@@ -99,12 +99,6 @@
|
||||
height: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.widget-header-more-options {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widget-full-view {
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.widget-header-hover {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.widget-api-actions {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import React, { MutableRefObject } from 'react';
|
||||
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import { MenuItemKeys } from '../contants';
|
||||
import WidgetHeader from '../index';
|
||||
|
||||
const TEST_WIDGET_TITLE = 'Test Widget';
|
||||
const TABLE_WIDGET_TITLE = 'Table Widget';
|
||||
const WIDGET_HEADER_SEARCH = 'widget-header-search';
|
||||
const WIDGET_HEADER_SEARCH_INPUT = 'widget-header-search-input';
|
||||
const TEST_WIDGET_TITLE_RESOLVED = 'Test Widget Title';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const createMockStore = (): ReturnType<typeof mockStore> =>
|
||||
mockStore({
|
||||
app: {
|
||||
role: 'ADMIN',
|
||||
user: {
|
||||
userId: 'test-user-id',
|
||||
email: 'test@signoz.io',
|
||||
name: 'TestUser',
|
||||
},
|
||||
isLoggedIn: true,
|
||||
org: [],
|
||||
},
|
||||
globalTime: {
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createMockAppContext = (): Partial<IAppContext> => ({
|
||||
user: {
|
||||
accessJwt: '',
|
||||
refreshJwt: '',
|
||||
id: '',
|
||||
email: '',
|
||||
displayName: '',
|
||||
createdAt: 0,
|
||||
organization: '',
|
||||
orgId: '',
|
||||
role: 'ADMIN' as ROLES,
|
||||
},
|
||||
});
|
||||
|
||||
const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
|
||||
rtlRender(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={createMockStore()}>
|
||||
<AppContext.Provider value={createMockAppContext() as IAppContext}>
|
||||
{ui}
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useCreateAlerts', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/dashboard/useGetResolvedText', () => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const TEST_WIDGET_TITLE_RESOLVED = 'Test Widget Title';
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
truncatedText: TEST_WIDGET_TITLE_RESOLVED,
|
||||
fullText: TEST_WIDGET_TITLE_RESOLVED,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
CircleX: (): JSX.Element => <svg data-testid="lucide-circle-x" />,
|
||||
TriangleAlert: (): JSX.Element => <svg data-testid="lucide-triangle-alert" />,
|
||||
X: (): JSX.Element => <svg data-testid="lucide-x" />,
|
||||
}));
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Spin: (): JSX.Element => <div data-testid="antd-spin" />,
|
||||
}));
|
||||
|
||||
const mockWidget: Widgets = {
|
||||
id: 'test-widget-id',
|
||||
title: TEST_WIDGET_TITLE,
|
||||
description: 'Test Description',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'query-id',
|
||||
queryType: 'builder' as EQueryType,
|
||||
},
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
opacity: '',
|
||||
nullZeroValues: '',
|
||||
yAxisUnit: '',
|
||||
fillSpans: false,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
};
|
||||
|
||||
const mockQueryResponse = ({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [],
|
||||
resultType: '',
|
||||
},
|
||||
},
|
||||
statusCode: 200,
|
||||
message: 'success',
|
||||
error: null,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
} as unknown) as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
},
|
||||
Error
|
||||
>;
|
||||
|
||||
describe('WidgetHeader', () => {
|
||||
const mockOnView = jest.fn();
|
||||
const mockSetSearchTerm = jest.fn();
|
||||
const tableProcessedDataRef: MutableRefObject<RowData[]> = {
|
||||
current: [
|
||||
{
|
||||
timestamp: 1234567890,
|
||||
key: 'key1',
|
||||
col1: 'val1',
|
||||
col2: 'val2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders widget header with title', () => {
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(TEST_WIDGET_TITLE_RESOLVED)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null for empty widget', () => {
|
||||
const emptyWidget = {
|
||||
...mockWidget,
|
||||
id: PANEL_TYPES.EMPTY_WIDGET,
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<WidgetHeader
|
||||
title="Empty Widget"
|
||||
widget={emptyWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows search input for table panels', async () => {
|
||||
const tableWidget = {
|
||||
...mockWidget,
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchIcon = screen.getByTestId(WIDGET_HEADER_SEARCH);
|
||||
expect(searchIcon).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchIcon);
|
||||
|
||||
expect(screen.getByTestId(WIDGET_HEADER_SEARCH_INPUT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles search input changes and closing', async () => {
|
||||
const tableWidget = {
|
||||
...mockWidget,
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchIcon = screen.getByTestId(`${WIDGET_HEADER_SEARCH}`);
|
||||
await userEvent.click(searchIcon);
|
||||
|
||||
const searchInput = screen.getByTestId(WIDGET_HEADER_SEARCH_INPUT);
|
||||
await userEvent.type(searchInput, 'test search');
|
||||
expect(mockSetSearchTerm).toHaveBeenCalledWith('test search');
|
||||
|
||||
const closeButton = screen
|
||||
.getByTestId(WIDGET_HEADER_SEARCH_INPUT)
|
||||
.parentElement?.querySelector('.search-header-icons');
|
||||
if (closeButton) {
|
||||
await userEvent.click(closeButton);
|
||||
expect(mockSetSearchTerm).toHaveBeenCalledWith('');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows error icon when query has error', () => {
|
||||
const errorResponse = {
|
||||
...mockQueryResponse,
|
||||
isError: true as const,
|
||||
error: { message: 'Test error' } as Error,
|
||||
data: undefined,
|
||||
} as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
},
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={errorResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
// check if CircleX icon is rendered
|
||||
const circleXIcon = screen.getByTestId('lucide-circle-x');
|
||||
expect(circleXIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning icon when query has warning', () => {
|
||||
const warningData = mockQueryResponse.data
|
||||
? {
|
||||
...mockQueryResponse.data,
|
||||
warning: {
|
||||
code: 'WARNING_CODE',
|
||||
message: 'Test warning',
|
||||
url: 'https://example.com',
|
||||
warnings: [{ message: 'Test warning' }],
|
||||
} as Warning,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const warningResponse = {
|
||||
...mockQueryResponse,
|
||||
data: warningData,
|
||||
} as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
},
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={warningResponse}
|
||||
isWarning
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const triangleAlertIcon = screen.getByTestId('lucide-triangle-alert');
|
||||
expect(triangleAlertIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinner when fetching response', () => {
|
||||
const fetchingResponse = {
|
||||
...mockQueryResponse,
|
||||
isFetching: true,
|
||||
isLoading: true,
|
||||
} as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
},
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={fetchingResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const antSpin = screen.getByTestId('antd-spin');
|
||||
expect(antSpin).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders menu options icon', () => {
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
headerMenuList={[MenuItemKeys.View]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const moreOptionsIcon = screen.getByTestId('widget-header-options');
|
||||
expect(moreOptionsIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search icon for table panels', () => {
|
||||
const tableWidget = {
|
||||
...mockWidget,
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
};
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TABLE_WIDGET_TITLE}
|
||||
widget={tableWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchIcon = screen.getByTestId(WIDGET_HEADER_SEARCH);
|
||||
expect(searchIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show search icon for non-table panels', () => {
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const searchIcon = screen.queryByTestId(WIDGET_HEADER_SEARCH);
|
||||
expect(searchIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold when provided', () => {
|
||||
const threshold = <div data-testid="threshold">Threshold Component</div>;
|
||||
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
widget={mockWidget}
|
||||
onView={mockOnView}
|
||||
queryResponse={mockQueryResponse}
|
||||
isWarning={false}
|
||||
isFetchingResponse={false}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
setSearchTerm={mockSetSearchTerm}
|
||||
threshold={threshold}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('threshold')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { buildAbsolutePath } from 'utils/app';
|
||||
|
||||
import { errorTooltipPosition } from './config';
|
||||
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
|
||||
@@ -48,6 +47,7 @@ interface IWidgetHeaderProps {
|
||||
onView: VoidFunction;
|
||||
onDelete?: VoidFunction;
|
||||
onClone?: VoidFunction;
|
||||
parentHover: boolean;
|
||||
queryResponse: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
@@ -68,6 +68,7 @@ function WidgetHeader({
|
||||
onView,
|
||||
onDelete,
|
||||
onClone,
|
||||
parentHover,
|
||||
queryResponse,
|
||||
threshold,
|
||||
headerMenuList,
|
||||
@@ -86,10 +87,7 @@ function WidgetHeader({
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(widget.query)),
|
||||
);
|
||||
const generatedUrl = buildAbsolutePath({
|
||||
relativePath: 'new',
|
||||
urlQueryString: urlQuery.toString(),
|
||||
});
|
||||
const generatedUrl = `${window.location.pathname}/new?${urlQuery}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}, [safeNavigate, urlQuery, widget.id, widget.panelTypes, widget.query]);
|
||||
|
||||
@@ -242,7 +240,6 @@ function WidgetHeader({
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setSearchTerm('');
|
||||
setShowGlobalSearch(false);
|
||||
}}
|
||||
className="search-header-icons"
|
||||
@@ -313,6 +310,8 @@ function WidgetHeader({
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${
|
||||
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -92,14 +92,14 @@ function BodyTitleRenderer({
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = JSON.stringify(value, null, 2);
|
||||
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
|
||||
} else if (parentIsArray) {
|
||||
// array elements
|
||||
copyText = `${value}`;
|
||||
// For array elements, copy just the value
|
||||
copyText = `"${cleanedKey}": ${value}`;
|
||||
} else {
|
||||
// primitive values
|
||||
const valueStr = typeof value === 'string' ? value : String(value);
|
||||
copyText = valueStr;
|
||||
// For primitive values, format as JSON key-value pair
|
||||
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
|
||||
copyText = `"${cleanedKey}": ${valueStr}`;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
@@ -60,8 +60,7 @@ const BodyContent: React.FC<{
|
||||
fieldData: Record<string, string>;
|
||||
record: DataType;
|
||||
bodyHtml: { __html: string };
|
||||
textToCopy: string;
|
||||
}> = React.memo(({ fieldData, record, bodyHtml, textToCopy }) => {
|
||||
}> = React.memo(({ fieldData, record, bodyHtml }) => {
|
||||
const { isLoading, treeData, error } = useAsyncJSONProcessing(
|
||||
fieldData.value,
|
||||
record.field === 'body',
|
||||
@@ -93,13 +92,11 @@ const BodyContent: React.FC<{
|
||||
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
</CopyClipboardHOC>
|
||||
<span
|
||||
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={bodyHtml} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,12 +172,7 @@ export default function TableViewActions(
|
||||
switch (record.field) {
|
||||
case 'body':
|
||||
return (
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
/>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
);
|
||||
|
||||
case 'timestamp':
|
||||
@@ -202,7 +194,6 @@ export default function TableViewActions(
|
||||
record,
|
||||
fieldData,
|
||||
bodyHtml,
|
||||
textToCopy,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
cleanTimestamp,
|
||||
]);
|
||||
@@ -211,12 +202,7 @@ export default function TableViewActions(
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<BodyContent
|
||||
fieldData={fieldData}
|
||||
record={record}
|
||||
bodyHtml={bodyHtml}
|
||||
textToCopy={textToCopy}
|
||||
/>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value">
|
||||
|
||||
@@ -1,54 +1,16 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
|
||||
|
||||
import TableViewActions from '../TableViewActions';
|
||||
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
|
||||
|
||||
// Mock data for tests
|
||||
let mockCopyToClipboard: jest.Mock;
|
||||
let mockNotificationsSuccess: jest.Mock;
|
||||
|
||||
// Mock the components and hooks
|
||||
jest.mock('components/Logs/CopyClipboardHOC', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
textToCopy,
|
||||
entityKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textToCopy: string;
|
||||
entityKey: string;
|
||||
}): JSX.Element => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
className="CopyClipboardHOC"
|
||||
data-testid={`copy-clipboard-${entityKey}`}
|
||||
data-text-to-copy={textToCopy}
|
||||
onClick={(): void => {
|
||||
if (mockCopyToClipboard) {
|
||||
mockCopyToClipboard(textToCopy);
|
||||
}
|
||||
if (mockNotificationsSuccess) {
|
||||
mockNotificationsSuccess({
|
||||
message: `${entityKey} copied to clipboard`,
|
||||
key: `${entityKey} copied to clipboard`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div className="CopyClipboardHOC">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../useAsyncJSONProcessing', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
|
||||
@@ -91,19 +53,6 @@ describe('TableViewActions', () => {
|
||||
onGroupByAttribute: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard = jest.fn();
|
||||
mockNotificationsSuccess = jest.fn();
|
||||
|
||||
// Default mock for useAsyncJSONProcessing
|
||||
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
|
||||
mockUseAsyncJSONProcessing.mockReturnValue({
|
||||
isLoading: false,
|
||||
treeData: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<TableViewActions
|
||||
@@ -178,60 +127,4 @@ describe('TableViewActions', () => {
|
||||
container.querySelector(ACTION_BUTTON_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should copy non-JSON body text without quotes when user clicks on body', () => {
|
||||
// Setup: body field with surrounding quotes
|
||||
const bodyValueWithQuotes =
|
||||
'"FeatureFlag \'kafkaQueueProblems\' is enabled, sleeping 1 second"';
|
||||
const expectedCopiedText =
|
||||
"FeatureFlag 'kafkaQueueProblems' is enabled, sleeping 1 second";
|
||||
|
||||
const bodyProps = {
|
||||
fieldData: {
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
record: {
|
||||
key: 'body-key',
|
||||
field: 'body',
|
||||
value: bodyValueWithQuotes,
|
||||
},
|
||||
isListViewPanel: false,
|
||||
isfilterInLoading: false,
|
||||
isfilterOutLoading: false,
|
||||
onClickHandler: jest.fn(),
|
||||
onGroupByAttribute: jest.fn(),
|
||||
};
|
||||
|
||||
// Render component with body field
|
||||
render(
|
||||
<TableViewActions
|
||||
fieldData={bodyProps.fieldData}
|
||||
record={bodyProps.record}
|
||||
isListViewPanel={bodyProps.isListViewPanel}
|
||||
isfilterInLoading={bodyProps.isfilterInLoading}
|
||||
isfilterOutLoading={bodyProps.isfilterOutLoading}
|
||||
onClickHandler={bodyProps.onClickHandler}
|
||||
onGroupByAttribute={bodyProps.onGroupByAttribute}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the clickable copy area for body
|
||||
const copyArea = screen.getByTestId('copy-clipboard-body');
|
||||
|
||||
// Verify it has the correct text to copy (without quotes)
|
||||
expect(copyArea).toHaveAttribute('data-text-to-copy', expectedCopiedText);
|
||||
|
||||
// Action: User clicks on body content
|
||||
fireEvent.click(copyArea);
|
||||
|
||||
// Assert: Text was copied without surrounding quotes
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(expectedCopiedText);
|
||||
|
||||
// Assert: Success notification shown
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'body copied to clipboard',
|
||||
key: 'body copied to clipboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('BodyTitleRenderer', () => {
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('John');
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
@@ -75,7 +75,7 @@ describe('BodyTitleRenderer', () => {
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('arrayElement');
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,8 +96,9 @@ describe('BodyTitleRenderer', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
const expectedJson = JSON.stringify(testObject, null, 2);
|
||||
expect(callArg).toBe(expectedJson);
|
||||
expect(callArg).toContain('"user.metadata":');
|
||||
expect(callArg).toContain('"id": 123');
|
||||
expect(callArg).toContain('"active": true');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { cleanup, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, QueryState } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import { explorerViewToPanelType } from 'utils/explorerUtils';
|
||||
|
||||
import LogExplorerQuerySection from './index';
|
||||
|
||||
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
|
||||
const QUERY_AGGREGATION_TEST_ID = 'query-aggregation-container';
|
||||
const QUERY_ADDON_TEST_ID = 'query-add-ons';
|
||||
|
||||
// Mock DOM APIs that CodeMirror needs
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects and getBoundingClientRect for Range objects
|
||||
const mockRect: DOMRect = {
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: (): DOMRect => mockRect,
|
||||
} as DOMRect;
|
||||
|
||||
// Create a minimal Range mock with only what CodeMirror actually uses
|
||||
const createMockRange = (): Range => {
|
||||
let startContainer: Node = document.createTextNode('');
|
||||
let endContainer: Node = document.createTextNode('');
|
||||
let startOffset = 0;
|
||||
let endOffset = 0;
|
||||
|
||||
const rectList = {
|
||||
length: 1,
|
||||
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
|
||||
0: mockRect,
|
||||
};
|
||||
|
||||
const mockRange = {
|
||||
// CodeMirror uses these for text measurement
|
||||
getClientRects: (): DOMRectList => (rectList as unknown) as DOMRectList,
|
||||
getBoundingClientRect: (): DOMRect => mockRect,
|
||||
// CodeMirror calls these to set up text ranges
|
||||
setStart: (node: Node, offset: number): void => {
|
||||
startContainer = node;
|
||||
startOffset = offset;
|
||||
},
|
||||
setEnd: (node: Node, offset: number): void => {
|
||||
endContainer = node;
|
||||
endOffset = offset;
|
||||
},
|
||||
// Minimal Range properties (TypeScript requires these)
|
||||
get startContainer(): Node {
|
||||
return startContainer;
|
||||
},
|
||||
get endContainer(): Node {
|
||||
return endContainer;
|
||||
},
|
||||
get startOffset(): number {
|
||||
return startOffset;
|
||||
},
|
||||
get endOffset(): number {
|
||||
return endOffset;
|
||||
},
|
||||
get collapsed(): boolean {
|
||||
return startContainer === endContainer && startOffset === endOffset;
|
||||
},
|
||||
commonAncestorContainer: document.body,
|
||||
};
|
||||
return (mockRange as unknown) as Range;
|
||||
};
|
||||
|
||||
// Mock document.createRange to return a new Range instance each time
|
||||
document.createRange = (): Range => createMockRange();
|
||||
|
||||
// Mock getBoundingClientRect for elements
|
||||
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): { selectedDashboard: undefined } => ({
|
||||
selectedDashboard: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: { keys: {} },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn().mockResolvedValue({
|
||||
data: { data: { values: { stringValues: [], numberValues: [] } } },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam');
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl');
|
||||
|
||||
const mockUseGetPanelTypesQueryParam = jest.mocked(useGetPanelTypesQueryParam);
|
||||
const mockUseShareBuilderUrl = jest.mocked(useShareBuilderUrl);
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn() as jest.MockedFunction<
|
||||
(query: Query, panelType: PANEL_TYPES, dataSource: DataSource) => Query
|
||||
>;
|
||||
|
||||
const mockResetQuery = jest.fn() as jest.MockedFunction<
|
||||
(newCurrentQuery?: QueryState) => void
|
||||
>;
|
||||
|
||||
const mockRedirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
|
||||
(query: Query) => void
|
||||
>;
|
||||
|
||||
// Create a mock query that we'll use to verify persistence
|
||||
const createMockQuery = (filterExpression?: string): Query => ({
|
||||
id: 'test-query-id',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
id: 'body--string----false',
|
||||
dataType: DataTypes.String,
|
||||
key: 'body',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'count',
|
||||
dataSource: DataSource.LOGS,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: filterExpression
|
||||
? {
|
||||
expression: filterExpression,
|
||||
}
|
||||
: undefined,
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: 'cloud.account.id',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
pageSize: 0,
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
stepInterval: 60,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
});
|
||||
|
||||
// Helper function to verify CodeMirror content
|
||||
const verifyCodeMirrorContent = async (
|
||||
expectedFilterExpression: string,
|
||||
): Promise<void> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const editorContent = document.querySelector(
|
||||
CM_EDITOR_SELECTOR,
|
||||
) as HTMLElement;
|
||||
expect(editorContent).toBeInTheDocument();
|
||||
const textContent = editorContent.textContent || '';
|
||||
expect(textContent).toBe(expectedFilterExpression);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
};
|
||||
|
||||
const VIEWS_TO_TEST = [
|
||||
ExplorerViews.LIST,
|
||||
ExplorerViews.TIMESERIES,
|
||||
ExplorerViews.TABLE,
|
||||
];
|
||||
|
||||
describe('LogExplorerQuerySection', () => {
|
||||
let mockQuery: Query;
|
||||
let mockQueryBuilderContext: Partial<QueryBuilderContextType>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockQuery = createMockQuery();
|
||||
|
||||
// Mock the return value of updateAllQueriesOperators to return the same query
|
||||
mockUpdateAllQueriesOperators.mockReturnValue(mockQuery);
|
||||
|
||||
// Setup query builder context mock
|
||||
mockQueryBuilderContext = {
|
||||
currentQuery: mockQuery,
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
resetQuery: mockResetQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
initialDataSource: DataSource.LOGS,
|
||||
addNewBuilderQuery: jest.fn() as jest.MockedFunction<() => void>,
|
||||
addNewFormula: jest.fn() as jest.MockedFunction<() => void>,
|
||||
handleSetConfig: jest.fn() as jest.MockedFunction<
|
||||
(panelType: PANEL_TYPES, dataSource: DataSource | null) => void
|
||||
>,
|
||||
addTraceOperator: jest.fn() as jest.MockedFunction<() => void>,
|
||||
};
|
||||
|
||||
// Mock useGetPanelTypesQueryParam
|
||||
mockUseGetPanelTypesQueryParam.mockReturnValue(PANEL_TYPES.LIST);
|
||||
|
||||
// Mock useShareBuilderUrl
|
||||
mockUseShareBuilderUrl.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should maintain query state across multiple view changes', () => {
|
||||
const { rerender } = render(
|
||||
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: mockQueryBuilderContext as QueryBuilderContextType,
|
||||
},
|
||||
);
|
||||
|
||||
const initialQuery = mockQueryBuilderContext.currentQuery;
|
||||
|
||||
VIEWS_TO_TEST.forEach((view) => {
|
||||
rerender(<LogExplorerQuerySection selectedView={view} />);
|
||||
expect(mockQueryBuilderContext.currentQuery).toEqual(initialQuery);
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist filter expressions across view changes', async () => {
|
||||
// Test with a more complex filter expression
|
||||
const complexFilter =
|
||||
"(service.name = 'api-gateway' OR service.name = 'backend') AND http.status_code IN [500, 502, 503] AND NOT error = 'timeout'";
|
||||
const queryWithComplexFilter = createMockQuery(complexFilter);
|
||||
|
||||
const contextWithComplexFilter: Partial<QueryBuilderContextType> = {
|
||||
...mockQueryBuilderContext,
|
||||
currentQuery: queryWithComplexFilter,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: contextWithComplexFilter as QueryBuilderContextType,
|
||||
},
|
||||
);
|
||||
|
||||
VIEWS_TO_TEST.forEach(async (view) => {
|
||||
rerender(<LogExplorerQuerySection selectedView={view} />);
|
||||
await verifyCodeMirrorContent(complexFilter);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render QueryAggregation and QueryAddOns when switching from LIST to TIMESERIES or TABLE view', async () => {
|
||||
// Helper function to verify components are rendered
|
||||
const verifyComponentsRendered = async (): Promise<void> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId(QUERY_AGGREGATION_TEST_ID)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId(QUERY_ADDON_TEST_ID)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
};
|
||||
|
||||
// Start with LIST view - QueryAggregation and QueryAddOns should NOT be rendered
|
||||
mockUseGetPanelTypesQueryParam.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const contextWithList: Partial<QueryBuilderContextType> = {
|
||||
...mockQueryBuilderContext,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
};
|
||||
|
||||
render(
|
||||
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: contextWithList as QueryBuilderContextType,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify QueryAggregation is NOT rendered in LIST view
|
||||
expect(
|
||||
screen.queryByTestId(QUERY_AGGREGATION_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify QueryAddOns is NOT rendered in LIST view (check for one of the add-on tabs)
|
||||
expect(screen.queryByTestId(QUERY_ADDON_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
|
||||
// Switch to TIMESERIES view
|
||||
const timeseriesPanelType = explorerViewToPanelType[ExplorerViews.TIMESERIES];
|
||||
mockUseGetPanelTypesQueryParam.mockReturnValue(timeseriesPanelType);
|
||||
const contextWithTimeseries: Partial<QueryBuilderContextType> = {
|
||||
...mockQueryBuilderContext,
|
||||
panelType: timeseriesPanelType,
|
||||
};
|
||||
|
||||
render(
|
||||
<LogExplorerQuerySection selectedView={ExplorerViews.TIMESERIES} />,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: contextWithTimeseries as QueryBuilderContextType,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify QueryAggregation and QueryAddOns are rendered
|
||||
await verifyComponentsRendered();
|
||||
|
||||
cleanup();
|
||||
|
||||
// Switch to TABLE view
|
||||
const tablePanelType = explorerViewToPanelType[ExplorerViews.TABLE];
|
||||
mockUseGetPanelTypesQueryParam.mockReturnValue(tablePanelType);
|
||||
const contextWithTable: Partial<QueryBuilderContextType> = {
|
||||
...mockQueryBuilderContext,
|
||||
panelType: tablePanelType,
|
||||
};
|
||||
|
||||
render(
|
||||
<LogExplorerQuerySection selectedView={ExplorerViews.TABLE} />,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: contextWithTable as QueryBuilderContextType,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify QueryAggregation and QueryAddOns are still rendered in TABLE view
|
||||
await verifyComponentsRendered();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||
import { useTableView } from 'components/Logs/TableView/useTableView';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
ColumnTitleIcon,
|
||||
ColumnTitleWrapper,
|
||||
} from 'container/OptionsMenu/styles';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -127,6 +133,12 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
.filter((column) => column.key)
|
||||
.map((column) => {
|
||||
const isDragColumn = column.key !== 'expand';
|
||||
const columnRecord = column as Record<string, unknown>;
|
||||
const hasUnselectedConflict =
|
||||
columnRecord._hasUnselectedConflict === true;
|
||||
const titleText = (column.title as string).replace(/^\w/, (c) =>
|
||||
c.toUpperCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<TableHeaderCellStyled
|
||||
@@ -139,7 +151,16 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
{...(isDragColumn && { className: `dragHandler ${column.key}` })}
|
||||
columnKey={column.key as string}
|
||||
>
|
||||
{(column.title as string).replace(/^\w/, (c) => c.toUpperCase())}
|
||||
<ColumnTitleWrapper>
|
||||
{titleText}
|
||||
{hasUnselectedConflict && (
|
||||
<Tooltip title="The same column with a different type or context exists">
|
||||
<ColumnTitleIcon>
|
||||
<InfoCircleOutlined />
|
||||
</ColumnTitleIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ColumnTitleWrapper>
|
||||
</TableHeaderCellStyled>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -60,7 +60,7 @@ function LogsExplorerList({
|
||||
onSetActiveLog,
|
||||
} = useActiveLog();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator:
|
||||
@@ -147,6 +147,7 @@ function LogsExplorerList({
|
||||
fontSize: options.fontSize,
|
||||
appendTo: 'end',
|
||||
activeLogIndex,
|
||||
allAvailableKeys: config.addColumn?.allAvailableKeys,
|
||||
}}
|
||||
infitiyTableProps={{ onEndReached }}
|
||||
/>
|
||||
@@ -195,6 +196,7 @@ function LogsExplorerList({
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
selectedFields,
|
||||
config.addColumn?.allAvailableKeys,
|
||||
]);
|
||||
|
||||
const isTraceToLogsNavigation = useMemo(() => {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ResizeTable } from 'components/ResizeTable';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Controls from 'container/Controls';
|
||||
import { extractTelemetryFieldKeys } from 'container/OptionsMenu/utils';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useLogsData } from 'hooks/useLogsData';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
@@ -27,6 +29,7 @@ import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { getLogPanelColumnsList } from './utils';
|
||||
|
||||
@@ -59,14 +62,31 @@ function LogsPanelComponent({
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Fetch available keys to detect variants
|
||||
|
||||
const { data: keysData } = useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: '',
|
||||
signal: DataSource.LOGS,
|
||||
},
|
||||
{
|
||||
queryKey: [DataSource.LOGS, LogsAggregatorOperator.NOOP, ''],
|
||||
},
|
||||
);
|
||||
|
||||
// Extract all available keys from API response
|
||||
const allAvailableKeys = useMemo(() => extractTelemetryFieldKeys(keysData), [
|
||||
keysData,
|
||||
]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getLogPanelColumnsList(
|
||||
widget.selectedLogFields,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
allAvailableKeys,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[widget.selectedLogFields],
|
||||
[widget.selectedLogFields, formatTimezoneAdjustedTimestamp, allAvailableKeys],
|
||||
);
|
||||
|
||||
const dataLength =
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mockAllAvailableKeys } from 'container/OptionsMenu/__tests__/mockData';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { renderColumnHeader } from 'tests/columnHeaderHelpers';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
|
||||
import { getLogPanelColumnsList } from '../utils';
|
||||
|
||||
const COLUMN_UNDEFINED_ERROR = 'statusCodeColumn is undefined';
|
||||
|
||||
// Mock the timezone formatter
|
||||
const mockFormatTimezoneAdjustedTimestamp = jest.fn(
|
||||
(input: TimestampInput): string => {
|
||||
if (typeof input === 'string') {
|
||||
return new Date(input).toISOString();
|
||||
}
|
||||
if (typeof input === 'number') {
|
||||
return new Date(input / 1e6).toISOString();
|
||||
}
|
||||
return new Date(input).toISOString();
|
||||
},
|
||||
);
|
||||
|
||||
describe('getLogPanelColumnsList - Column Headers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows tooltip icon when conflicting variant exists in allAvailableKeys', () => {
|
||||
// Even with single variant selected, tooltip should appear if conflicting variant exists
|
||||
const selectedLogFields: IField[] = [
|
||||
{
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
name: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
} as IField,
|
||||
];
|
||||
|
||||
const columns = getLogPanelColumnsList(
|
||||
selectedLogFields,
|
||||
mockFormatTimezoneAdjustedTimestamp,
|
||||
mockAllAvailableKeys, // Contains number variant
|
||||
);
|
||||
|
||||
const statusCodeColumn = columns.find(
|
||||
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
|
||||
);
|
||||
|
||||
expect(statusCodeColumn).toBeDefined();
|
||||
expect(statusCodeColumn?.title).toBeDefined();
|
||||
|
||||
// Verify that _hasUnselectedConflict metadata is set correctly
|
||||
const columnRecord = statusCodeColumn as Record<string, unknown>;
|
||||
expect(columnRecord._hasUnselectedConflict).toBe(true);
|
||||
|
||||
if (!statusCodeColumn) {
|
||||
throw new Error(COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(statusCodeColumn);
|
||||
expect(container.textContent).toContain('http.status_code (string)');
|
||||
|
||||
// Tooltip icon should appear
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const tooltipIcon = container.querySelector('.anticon-info-circle');
|
||||
expect(tooltipIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides tooltip icon when all conflicting variants are selected', () => {
|
||||
const selectedLogFields: IField[] = [
|
||||
{
|
||||
name: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
} as IField,
|
||||
{
|
||||
name: 'http.status_code',
|
||||
dataType: 'number',
|
||||
type: 'attribute',
|
||||
} as IField,
|
||||
];
|
||||
|
||||
const columns = getLogPanelColumnsList(
|
||||
selectedLogFields,
|
||||
mockFormatTimezoneAdjustedTimestamp,
|
||||
mockAllAvailableKeys,
|
||||
);
|
||||
|
||||
const statusCodeColumn = columns.find(
|
||||
(col) => 'dataIndex' in col && col.dataIndex === 'http.status_code',
|
||||
);
|
||||
|
||||
expect(statusCodeColumn).toBeDefined();
|
||||
|
||||
// Verify that _hasUnselectedConflict metadata is NOT set when all variants are selected
|
||||
const columnRecord = statusCodeColumn as Record<string, unknown>;
|
||||
expect(columnRecord._hasUnselectedConflict).toBeUndefined();
|
||||
|
||||
if (!statusCodeColumn) {
|
||||
throw new Error(COLUMN_UNDEFINED_ERROR);
|
||||
}
|
||||
|
||||
const { container } = renderColumnHeader(statusCodeColumn);
|
||||
const tooltipIcon = container.querySelector('.anticon-info-circle');
|
||||
expect(tooltipIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { Typography } from 'antd/lib';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
getColumnTitleWithTooltip,
|
||||
getFieldVariantsByName,
|
||||
getUniqueColumnKey,
|
||||
hasMultipleVariants,
|
||||
} from 'container/OptionsMenu/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
// import Typography from 'antd/es/typography/Typography';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
@@ -13,17 +20,35 @@ export const getLogPanelColumnsList = (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string,
|
||||
allAvailableKeys?: TelemetryFieldKey[],
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [];
|
||||
|
||||
// Group fields by name to analyze variants
|
||||
const fieldVariantsByName = getFieldVariantsByName(selectedLogFields || []);
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedLogFields?.map((field: IField) => {
|
||||
const { name } = field;
|
||||
const hasVariants = hasMultipleVariants(
|
||||
name,
|
||||
selectedLogFields || [],
|
||||
allAvailableKeys,
|
||||
);
|
||||
const variants = fieldVariantsByName[name] || [];
|
||||
const { title, hasUnselectedConflict } = getColumnTitleWithTooltip(
|
||||
field,
|
||||
hasVariants,
|
||||
variants,
|
||||
selectedLogFields || [],
|
||||
allAvailableKeys,
|
||||
);
|
||||
|
||||
return {
|
||||
title: name,
|
||||
title,
|
||||
dataIndex: name,
|
||||
key: name,
|
||||
key: getUniqueColumnKey(field),
|
||||
...(hasUnselectedConflict && { _hasUnselectedConflict: true }),
|
||||
width: name === 'body' ? 350 : 100,
|
||||
render: (value: ReactNode): JSX.Element => {
|
||||
if (name === 'timestamp') {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { Checkbox, Empty } from 'antd';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
import {
|
||||
getUniqueColumnKey,
|
||||
getVariantCounts,
|
||||
} from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
QueryKeyDataSuggestionsProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
type ExplorerAttributeColumnsProps = {
|
||||
isLoading: boolean;
|
||||
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
|
||||
searchText: string;
|
||||
isAttributeKeySelected: (key: string) => boolean;
|
||||
handleCheckboxChange: (key: string) => void;
|
||||
isAttributeKeySelected: (
|
||||
attributeKey: QueryKeyDataSuggestionsProps,
|
||||
) => boolean;
|
||||
handleCheckboxChange: (attributeKey: QueryKeyDataSuggestionsProps) => void;
|
||||
dataSource: DataSource;
|
||||
};
|
||||
|
||||
@@ -38,6 +49,12 @@ function ExplorerAttributeColumns({
|
||||
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
|
||||
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
|
||||
) || [];
|
||||
|
||||
// Detect which column names have multiple variants
|
||||
const nameCounts = getVariantCounts(
|
||||
filteredAttributeKeys as TelemetryFieldKey[],
|
||||
);
|
||||
|
||||
if (filteredAttributeKeys.length === 0) {
|
||||
return (
|
||||
<div className="attribute-columns">
|
||||
@@ -48,16 +65,26 @@ function ExplorerAttributeColumns({
|
||||
|
||||
return (
|
||||
<div className="attribute-columns">
|
||||
{filteredAttributeKeys.map((attributeKey: any) => (
|
||||
<Checkbox
|
||||
checked={isAttributeKeySelected(attributeKey.name)}
|
||||
onChange={(): void => handleCheckboxChange(attributeKey.name)}
|
||||
style={{ padding: 0 }}
|
||||
key={attributeKey.name}
|
||||
>
|
||||
{attributeKey.name}
|
||||
</Checkbox>
|
||||
))}
|
||||
{filteredAttributeKeys.map((attributeKey) => {
|
||||
const hasVariants = nameCounts[attributeKey.name] > 1;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={isAttributeKeySelected(attributeKey)}
|
||||
onChange={(): void => handleCheckboxChange(attributeKey)}
|
||||
key={getUniqueColumnKey(attributeKey)}
|
||||
>
|
||||
<span className="attribute-column-label-wrapper">
|
||||
<span>{attributeKey.name}</span>
|
||||
{hasVariants && (
|
||||
<FieldVariantBadges
|
||||
fieldDataType={attributeKey.fieldDataType}
|
||||
fieldContext={attributeKey.fieldContext}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
cursor: grab;
|
||||
|
||||
.column-name-wrapper,
|
||||
.badges-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.lucide-trash2 {
|
||||
@@ -114,6 +121,16 @@
|
||||
flex-direction: column;
|
||||
height: 160px;
|
||||
overflow: scroll;
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
padding: 0 !important;
|
||||
|
||||
.attribute-column-label-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar {
|
||||
|
||||
@@ -6,8 +6,13 @@ import './ExplorerColumnsRenderer.styles.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Dropdown, Input, Tooltip, Typography } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { FieldDataType } from 'api/v5/v5';
|
||||
import { FieldDataType, TelemetryFieldKey } from 'api/v5/v5';
|
||||
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import {
|
||||
getUniqueColumnKey,
|
||||
getVariantCounts,
|
||||
} from 'container/OptionsMenu/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -26,6 +31,7 @@ import {
|
||||
Droppable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { WidgetGraphProps } from '../types';
|
||||
@@ -82,64 +88,87 @@ function ExplorerColumnsRenderer({
|
||||
},
|
||||
);
|
||||
|
||||
const isAttributeKeySelected = (key: string): boolean => {
|
||||
const isAttributeKeySelected = (attribute: any): boolean => {
|
||||
const uniqueKey = getUniqueColumnKey(attribute);
|
||||
|
||||
if (initialDataSource === DataSource.LOGS && selectedLogFields) {
|
||||
return selectedLogFields.some((field) => field.name === key);
|
||||
return selectedLogFields.some(
|
||||
(field) => getUniqueColumnKey(field) === uniqueKey,
|
||||
);
|
||||
}
|
||||
if (initialDataSource === DataSource.TRACES && selectedTracesFields) {
|
||||
return selectedTracesFields.some((field) => field.name === key);
|
||||
return selectedTracesFields.some(
|
||||
(field) => getUniqueColumnKey(field) === uniqueKey,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (key: string): void => {
|
||||
const handleCheckboxChange = (attribute: any): void => {
|
||||
const uniqueKey = getUniqueColumnKey(attribute);
|
||||
|
||||
if (
|
||||
initialDataSource === DataSource.LOGS &&
|
||||
setSelectedLogFields !== undefined
|
||||
) {
|
||||
if (selectedLogFields) {
|
||||
if (isAttributeKeySelected(key)) {
|
||||
if (isAttributeKeySelected(attribute)) {
|
||||
setSelectedLogFields(
|
||||
selectedLogFields.filter((field) => field.name !== key),
|
||||
selectedLogFields.filter(
|
||||
(field) => getUniqueColumnKey(field) !== uniqueKey,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedLogFields([
|
||||
...selectedLogFields,
|
||||
{ dataType: 'string', name: key, type: '' },
|
||||
{
|
||||
name: attribute.name,
|
||||
dataType: attribute.fieldDataType || 'string',
|
||||
type: attribute.fieldContext || '',
|
||||
fieldDataType: attribute.fieldDataType || 'string',
|
||||
fieldContext: attribute.fieldContext || '',
|
||||
} as IField & { fieldDataType: string; fieldContext: string },
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setSelectedLogFields([{ dataType: 'string', name: key, type: '' }]);
|
||||
setSelectedLogFields([
|
||||
{
|
||||
name: attribute.name,
|
||||
dataType: attribute.fieldDataType || 'string',
|
||||
type: attribute.fieldContext || '',
|
||||
fieldDataType: attribute.fieldDataType || 'string',
|
||||
fieldContext: attribute.fieldContext || '',
|
||||
} as IField & { fieldDataType: string; fieldContext: string },
|
||||
]);
|
||||
}
|
||||
} else if (
|
||||
initialDataSource === DataSource.TRACES &&
|
||||
setSelectedTracesFields !== undefined
|
||||
) {
|
||||
const selectedField = Object.values(data?.data?.data?.keys || {})
|
||||
?.flat()
|
||||
?.find((attributeKey) => attributeKey.name === key);
|
||||
|
||||
if (selectedTracesFields) {
|
||||
if (isAttributeKeySelected(key)) {
|
||||
if (isAttributeKeySelected(attribute)) {
|
||||
setSelectedTracesFields(
|
||||
selectedTracesFields.filter((field) => field.name !== key),
|
||||
selectedTracesFields.filter(
|
||||
(field) => getUniqueColumnKey(field) !== uniqueKey,
|
||||
),
|
||||
);
|
||||
} else if (selectedField) {
|
||||
} else {
|
||||
setSelectedTracesFields([
|
||||
...selectedTracesFields,
|
||||
{
|
||||
...selectedField,
|
||||
fieldDataType: selectedField.fieldDataType as FieldDataType,
|
||||
...attribute,
|
||||
fieldDataType: attribute.fieldDataType as FieldDataType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (selectedField)
|
||||
} else {
|
||||
setSelectedTracesFields([
|
||||
{
|
||||
...selectedField,
|
||||
fieldDataType: selectedField.fieldDataType as FieldDataType,
|
||||
...attribute,
|
||||
fieldDataType: attribute.fieldDataType as FieldDataType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
@@ -189,14 +218,18 @@ function ExplorerColumnsRenderer({
|
||||
},
|
||||
];
|
||||
|
||||
const removeSelectedLogField = (name: string): void => {
|
||||
const removeSelectedLogField = (field: any): void => {
|
||||
const uniqueKey = getUniqueColumnKey(field);
|
||||
|
||||
if (
|
||||
initialDataSource === DataSource.LOGS &&
|
||||
setSelectedLogFields &&
|
||||
selectedLogFields
|
||||
) {
|
||||
setSelectedLogFields(
|
||||
selectedLogFields.filter((field) => field.name !== name),
|
||||
selectedLogFields.filter(
|
||||
(field) => getUniqueColumnKey(field) !== uniqueKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -205,7 +238,9 @@ function ExplorerColumnsRenderer({
|
||||
selectedTracesFields
|
||||
) {
|
||||
setSelectedTracesFields(
|
||||
selectedTracesFields.filter((field) => field.name !== name),
|
||||
selectedTracesFields.filter(
|
||||
(field) => getUniqueColumnKey(field) !== uniqueKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -248,6 +283,11 @@ function ExplorerColumnsRenderer({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Detect which column names have multiple variants from API data
|
||||
const allAttributeKeys =
|
||||
Object.values(data?.data?.data?.keys || {})?.flat() || [];
|
||||
const nameCounts = getVariantCounts(allAttributeKeys as TelemetryFieldKey[]);
|
||||
|
||||
return (
|
||||
<div className="explorer-columns-renderer">
|
||||
<div className="title">
|
||||
@@ -271,7 +311,7 @@ function ExplorerColumnsRenderer({
|
||||
>
|
||||
{initialDataSource === DataSource.LOGS &&
|
||||
selectedLogFields &&
|
||||
selectedLogFields.map((field, index) => (
|
||||
selectedLogFields.map((field: TelemetryFieldKey, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Draggable key={index} draggableId={index.toString()} index={index}>
|
||||
{(dragProvided): JSX.Element => (
|
||||
@@ -283,12 +323,22 @@ function ExplorerColumnsRenderer({
|
||||
>
|
||||
<div className="explorer-column-title">
|
||||
<GripVertical size={12} color="#5A5A5A" />
|
||||
{field.name}
|
||||
<span className="column-name-wrapper">
|
||||
{field.name}
|
||||
{nameCounts[field.name] > 1 && (
|
||||
<span className="badges-container">
|
||||
<FieldVariantBadges
|
||||
fieldDataType={field.fieldDataType}
|
||||
fieldContext={field.fieldContext}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Trash2
|
||||
size={12}
|
||||
color="red"
|
||||
onClick={(): void => removeSelectedLogField(field.name)}
|
||||
onClick={(): void => removeSelectedLogField(field)}
|
||||
data-testid="trash-icon"
|
||||
/>
|
||||
</div>
|
||||
@@ -309,14 +359,22 @@ function ExplorerColumnsRenderer({
|
||||
>
|
||||
<div className="explorer-column-title">
|
||||
<GripVertical size={12} color="#5A5A5A" />
|
||||
{field?.name || (field as any)?.key}
|
||||
<span className="column-name-wrapper">
|
||||
{field?.name || field?.key}
|
||||
{nameCounts[field?.name || ''] > 1 && (
|
||||
<span className="badges-container">
|
||||
<FieldVariantBadges
|
||||
fieldDataType={field.fieldDataType}
|
||||
fieldContext={field.fieldContext}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Trash2
|
||||
size={12}
|
||||
color="red"
|
||||
onClick={(): void =>
|
||||
removeSelectedLogField(field?.name || (field as any)?.key)
|
||||
}
|
||||
onClick={(): void => removeSelectedLogField(field)}
|
||||
data-testid="trash-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -222,7 +222,13 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(mockSetSelectedLogFields).toHaveBeenCalledWith([
|
||||
{ dataType: 'string', name: 'attribute1', type: '' },
|
||||
{
|
||||
dataType: 'string',
|
||||
fieldContext: '',
|
||||
fieldDataType: 'string',
|
||||
name: 'attribute1',
|
||||
type: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -326,9 +332,21 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{ name: 'trace_attribute1', dataType: 'string', type: 'tag' },
|
||||
{ name: 'trace_attribute2', dataType: 'string', type: 'tag' },
|
||||
trace_attribute1: [
|
||||
{
|
||||
name: 'trace_attribute1',
|
||||
fieldDataType: DataTypes.String,
|
||||
fieldContext: '',
|
||||
signal: 'traces',
|
||||
},
|
||||
],
|
||||
trace_attribute2: [
|
||||
{
|
||||
name: 'trace_attribute2',
|
||||
fieldDataType: DataTypes.String,
|
||||
fieldContext: '',
|
||||
signal: 'traces',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -356,7 +374,12 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(mockSetSelectedTracesFields).toHaveBeenCalledWith([
|
||||
{ name: 'trace_attribute1', dataType: 'string', type: 'tag' },
|
||||
{
|
||||
name: 'trace_attribute1',
|
||||
fieldDataType: DataTypes.String,
|
||||
fieldContext: '',
|
||||
signal: 'traces',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ function NewWidget({
|
||||
stackedBarChart: selectedWidget?.stackedBarChart || false,
|
||||
yAxisUnit: selectedWidget?.yAxisUnit,
|
||||
decimalPrecision:
|
||||
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
|
||||
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
|
||||
panelTypes: graphType,
|
||||
query: adjustedQueryForV5,
|
||||
thresholds: selectedWidget?.thresholds,
|
||||
@@ -532,7 +532,7 @@ function NewWidget({
|
||||
stackedBarChart: selectedWidget?.stackedBarChart || false,
|
||||
yAxisUnit: selectedWidget?.yAxisUnit,
|
||||
decimalPrecision:
|
||||
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
|
||||
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
|
||||
panelTypes: graphType,
|
||||
query: adjustedQueryForV5,
|
||||
thresholds: selectedWidget?.thresholds,
|
||||
|
||||
@@ -516,8 +516,6 @@
|
||||
"falcon",
|
||||
"fastapi",
|
||||
"flask",
|
||||
"celery",
|
||||
"gunicorn",
|
||||
"monitor python application",
|
||||
"monitor python backend",
|
||||
"opentelemetry python",
|
||||
@@ -542,33 +540,134 @@
|
||||
],
|
||||
"id": "python",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"desc": "Which Python framework do you use?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"entityID": "framework",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
"key": "flask",
|
||||
"label": "Flask",
|
||||
"imgUrl": "/Logos/flask.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/"
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
"key": "django",
|
||||
"label": "Django",
|
||||
"imgUrl": "/Logos/django.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/"
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "windows",
|
||||
"label": "Windows",
|
||||
"imgUrl": "/Logos/windows.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
"key": "fastapi",
|
||||
"label": "FastAPI",
|
||||
"imgUrl": "/Logos/fastapi.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/"
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "docker",
|
||||
"label": "Docker",
|
||||
"imgUrl": "/Logos/docker.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
"key": "falcon",
|
||||
"label": "Falcon",
|
||||
"imgUrl": "/Logos/falcon.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/"
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "others",
|
||||
"label": "Others",
|
||||
"imgUrl": "/Logos/python-others.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/",
|
||||
"question": {
|
||||
"desc": "What is your Environment?",
|
||||
"type": "select",
|
||||
"entityID": "environment",
|
||||
"options": [
|
||||
{
|
||||
"key": "vm",
|
||||
"label": "VM",
|
||||
"imgUrl": "/Logos/vm.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
},
|
||||
{
|
||||
"key": "k8s",
|
||||
"label": "Kubernetes",
|
||||
"imgUrl": "/Logos/kubernetes.svg",
|
||||
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5323,7 +5422,7 @@
|
||||
"imgUrl": "/Logos/logs.svg",
|
||||
"tags": [
|
||||
"Frontend Monitoring",
|
||||
"logs"
|
||||
"logs"
|
||||
],
|
||||
"module": "logs",
|
||||
"relatedSearchKeywords": [
|
||||
@@ -5442,4 +5541,4 @@
|
||||
],
|
||||
"link": "https://signoz.io/docs/userguide/envoy-metrics/"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
|
||||
import {
|
||||
mockAllAvailableKeys,
|
||||
mockConflictingFieldsByContext,
|
||||
mockConflictingFieldsByDatatype,
|
||||
mockNonConflictingField,
|
||||
} from '../../__tests__/mockData';
|
||||
import AddColumnField from '../index';
|
||||
|
||||
describe('AddColumnField - Badge Display', () => {
|
||||
const defaultConfig = {
|
||||
isFetching: false,
|
||||
options: [],
|
||||
value: [],
|
||||
onSelect: jest.fn(),
|
||||
onFocus: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
onSearch: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
allAvailableKeys: mockAllAvailableKeys,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows badge for single selected conflicting field (different datatype)', () => {
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByDatatype[0], // Only string variant selected
|
||||
];
|
||||
|
||||
render(
|
||||
<AddColumnField
|
||||
config={{
|
||||
...defaultConfig,
|
||||
value: selectedColumns,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Badge should appear even though only one variant is selected
|
||||
// because allAvailableKeys contains the conflicting variant
|
||||
const badgeContainer = screen.queryByText('http.status_code')?.closest('div');
|
||||
expect(badgeContainer).toBeInTheDocument();
|
||||
|
||||
// Check for datatype badge
|
||||
const datatypeBadge = screen.queryByText('string');
|
||||
expect(datatypeBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows badges for multiple conflicting fields selected', () => {
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
...mockConflictingFieldsByDatatype, // Both string and number variants
|
||||
];
|
||||
|
||||
render(
|
||||
<AddColumnField
|
||||
config={{
|
||||
...defaultConfig,
|
||||
value: selectedColumns,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both variants should show badges
|
||||
const stringBadge = screen.getByText('string');
|
||||
const numberBadge = screen.getByText('number');
|
||||
expect(stringBadge).toBeInTheDocument();
|
||||
expect(numberBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows badges when all conflicting variants are selected', () => {
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
...mockConflictingFieldsByDatatype, // All variants selected
|
||||
];
|
||||
|
||||
render(
|
||||
<AddColumnField
|
||||
config={{
|
||||
...defaultConfig,
|
||||
value: selectedColumns,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both variants should appear as separate items in the list
|
||||
const fieldNames = screen.getAllByText('http.status_code');
|
||||
expect(fieldNames).toHaveLength(2); // One for each variant
|
||||
|
||||
// Badges should still be visible when all variants are selected
|
||||
const stringBadge = screen.getByText('string');
|
||||
const numberBadge = screen.getByText('number');
|
||||
expect(stringBadge).toBeInTheDocument();
|
||||
expect(numberBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show badge for non-conflicting field', () => {
|
||||
const selectedColumns: TelemetryFieldKey[] = [...mockNonConflictingField];
|
||||
|
||||
render(
|
||||
<AddColumnField
|
||||
config={{
|
||||
...defaultConfig,
|
||||
value: selectedColumns,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Field name should be visible
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
|
||||
// But no badge should appear (no conflicting variants)
|
||||
const badgeContainer = document.querySelector(
|
||||
'.field-variant-badges-container',
|
||||
);
|
||||
expect(badgeContainer).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows context badge for attribute/resource conflicting fields', () => {
|
||||
const selectedColumns: TelemetryFieldKey[] = [
|
||||
mockConflictingFieldsByContext[0], // resource variant
|
||||
];
|
||||
|
||||
render(
|
||||
<AddColumnField
|
||||
config={{
|
||||
...defaultConfig,
|
||||
value: selectedColumns,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Context badge should appear for resource
|
||||
const contextBadge = screen.queryByText('resource');
|
||||
expect(contextBadge).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,39 @@
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Input, Spin, Typography } from 'antd';
|
||||
import { Input, Spin } from 'antd';
|
||||
import { BaseOptionType } from 'antd/es/select';
|
||||
import FieldVariantBadges from 'components/FieldVariantBadges/FieldVariantBadges';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FieldTitle } from '../styles';
|
||||
import { OptionsMenuConfig } from '../types';
|
||||
import { getUniqueColumnKey, hasMultipleVariants } from '../utils';
|
||||
import {
|
||||
AddColumnItem,
|
||||
AddColumnSelect,
|
||||
AddColumnWrapper,
|
||||
DeleteOutlinedIcon,
|
||||
Name,
|
||||
NameWrapper,
|
||||
OptionContent,
|
||||
SearchIconWrapper,
|
||||
} from './styles';
|
||||
|
||||
function OptionRenderer(option: BaseOptionType): JSX.Element {
|
||||
const { label, data } = option;
|
||||
return (
|
||||
<OptionContent>
|
||||
<span className="option-label">{label}</span>
|
||||
{data?.hasMultipleVariants && (
|
||||
<FieldVariantBadges
|
||||
fieldDataType={data?.fieldDataType}
|
||||
fieldContext={data?.fieldContext}
|
||||
/>
|
||||
)}
|
||||
</OptionContent>
|
||||
);
|
||||
}
|
||||
|
||||
function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -36,18 +57,35 @@ function AddColumnField({ config }: AddColumnFieldProps): JSX.Element | null {
|
||||
onFocus={config.onFocus}
|
||||
onBlur={config.onBlur}
|
||||
notFoundContent={config.isFetching ? <Spin size="small" /> : null}
|
||||
optionRender={OptionRenderer}
|
||||
/>
|
||||
<SearchIconWrapper $isDarkMode={isDarkMode}>
|
||||
<SearchOutlined />
|
||||
</SearchIconWrapper>
|
||||
</Input.Group>
|
||||
|
||||
{config.value?.map(({ name }) => (
|
||||
<AddColumnItem direction="horizontal" key={name}>
|
||||
<Typography>{name}</Typography>
|
||||
<DeleteOutlinedIcon onClick={(): void => config.onRemove(name)} />
|
||||
</AddColumnItem>
|
||||
))}
|
||||
{config.value?.map((column) => {
|
||||
const uniqueKey = getUniqueColumnKey(column);
|
||||
const showBadge = hasMultipleVariants(
|
||||
column.name || '',
|
||||
config.value || [],
|
||||
config.allAvailableKeys,
|
||||
);
|
||||
return (
|
||||
<AddColumnItem key={uniqueKey}>
|
||||
<NameWrapper>
|
||||
<Name>{column.name}</Name>
|
||||
{showBadge && (
|
||||
<FieldVariantBadges
|
||||
fieldDataType={column.fieldDataType}
|
||||
fieldContext={column.fieldContext}
|
||||
/>
|
||||
)}
|
||||
</NameWrapper>
|
||||
<DeleteOutlinedIcon onClick={(): void => config.onRemove(uniqueKey)} />
|
||||
</AddColumnItem>
|
||||
);
|
||||
})}
|
||||
</AddColumnWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const AddColumnWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const AddColumnItem = styled(Space)`
|
||||
export const AddColumnItem = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -37,3 +37,35 @@ export const AddColumnItem = styled(Space)`
|
||||
export const DeleteOutlinedIcon = styled(DeleteOutlined)`
|
||||
color: red;
|
||||
`;
|
||||
|
||||
export const OptionContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
export const NameWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: calc(100% - 26px);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
`;
|
||||
export const Name = styled.span`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
111
frontend/src/container/OptionsMenu/__tests__/mockData.ts
Normal file
111
frontend/src/container/OptionsMenu/__tests__/mockData.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
const HTTP_STATUS_CODE = 'http.status_code';
|
||||
const SERVICE_NAME = 'service.name';
|
||||
|
||||
// Conflicting fields: same name, different datatype
|
||||
export const mockConflictingFieldsByDatatype: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.NUMBER,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
},
|
||||
];
|
||||
|
||||
// Conflicting fields: same name, different context
|
||||
export const mockConflictingFieldsByContext: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: SERVICE_NAME,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'resource',
|
||||
signal: 'traces',
|
||||
},
|
||||
{
|
||||
name: SERVICE_NAME,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
},
|
||||
];
|
||||
|
||||
// Non-conflicting field (single variant)
|
||||
export const mockNonConflictingField: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'trace_id',
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock API response structure for conflicting fields by datatype
|
||||
export const mockQueryKeySuggestionsResponseByDatatype: QueryKeySuggestionsResponseProps = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
[HTTP_STATUS_CODE]: [
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
label: HTTP_STATUS_CODE,
|
||||
type: 'attribute',
|
||||
},
|
||||
{
|
||||
name: HTTP_STATUS_CODE,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.NUMBER,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
label: HTTP_STATUS_CODE,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock API response structure for conflicting fields by context
|
||||
export const mockQueryKeySuggestionsResponseByContext: QueryKeySuggestionsResponseProps = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
[SERVICE_NAME]: [
|
||||
{
|
||||
name: SERVICE_NAME,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'resource',
|
||||
signal: 'traces',
|
||||
label: SERVICE_NAME,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: SERVICE_NAME,
|
||||
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
|
||||
fieldContext: 'attribute',
|
||||
signal: 'traces',
|
||||
label: SERVICE_NAME,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// All available keys (for allAvailableKeys prop)
|
||||
export const mockAllAvailableKeys: TelemetryFieldKey[] = [
|
||||
...mockConflictingFieldsByDatatype,
|
||||
...mockConflictingFieldsByContext,
|
||||
...mockNonConflictingField,
|
||||
];
|
||||
@@ -10,10 +10,22 @@ export const OptionsContainer = styled(Card)`
|
||||
`;
|
||||
|
||||
export const OptionsContentWrapper = styled(Space)`
|
||||
min-width: 11rem;
|
||||
width: 21rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
`;
|
||||
|
||||
export const FieldTitle = styled(Typography.Text)`
|
||||
font-size: 0.75rem;
|
||||
`;
|
||||
|
||||
export const ColumnTitleWrapper = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
export const ColumnTitleIcon = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
`;
|
||||
|
||||
@@ -38,5 +38,6 @@ export type OptionsMenuConfig = {
|
||||
isFetching: boolean;
|
||||
value: TelemetryFieldKey[];
|
||||
onRemove: (key: string) => void;
|
||||
allAvailableKeys?: TelemetryFieldKey[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
OptionsMenuConfig,
|
||||
OptionsQuery,
|
||||
} from './types';
|
||||
import { getOptionsFromKeys } from './utils';
|
||||
import { getOptionsFromKeys, getUniqueColumnKey } from './utils';
|
||||
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
@@ -170,7 +170,7 @@ const useOptionsMenu = ({
|
||||
...initialQueryParamsV5,
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{ queryKey: [debouncedSearchText, isFocused], enabled: isFocused },
|
||||
{ queryKey: [debouncedSearchText, isFocused] },
|
||||
);
|
||||
|
||||
// const {
|
||||
@@ -186,7 +186,7 @@ const useOptionsMenu = ({
|
||||
|
||||
const searchedAttributeKeys: TelemetryFieldKey[] = useMemo(() => {
|
||||
const searchedAttributesDataList = Object.values(
|
||||
searchedAttributesDataV5?.data.data.keys || {},
|
||||
searchedAttributesDataV5?.data?.data?.keys || {},
|
||||
).flat();
|
||||
if (searchedAttributesDataList.length) {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
@@ -230,7 +230,7 @@ const useOptionsMenu = ({
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
|
||||
}, [dataSource, searchedAttributesDataV5?.data?.data?.keys]);
|
||||
|
||||
const initialOptionsQuery: OptionsQuery = useMemo(() => {
|
||||
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
|
||||
@@ -262,7 +262,7 @@ const useOptionsMenu = ({
|
||||
}, [dataSource, initialOptions, initialSelectedColumns]);
|
||||
|
||||
const selectedColumnKeys = useMemo(
|
||||
() => preferences?.columns?.map(({ name }) => name) || [],
|
||||
() => preferences?.columns?.map((col) => getUniqueColumnKey(col)) || [],
|
||||
[preferences?.columns],
|
||||
);
|
||||
|
||||
@@ -287,16 +287,14 @@ const useOptionsMenu = ({
|
||||
|
||||
const handleSelectColumns = useCallback(
|
||||
(value: string) => {
|
||||
const newSelectedColumnKeys = [...new Set([...selectedColumnKeys, value])];
|
||||
const newSelectedColumns = newSelectedColumnKeys.reduce((acc, key) => {
|
||||
const column = [
|
||||
...searchedAttributeKeys,
|
||||
...(preferences?.columns || []),
|
||||
].find(({ name }) => name === key);
|
||||
// value is now the unique key (name::dataType::context)
|
||||
const column = searchedAttributeKeys.find(
|
||||
(key) => getUniqueColumnKey(key) === value,
|
||||
);
|
||||
|
||||
if (!column) return acc;
|
||||
return [...acc, column];
|
||||
}, [] as TelemetryFieldKey[]);
|
||||
if (!column) return;
|
||||
|
||||
const newSelectedColumns = [...(preferences?.columns || []), column];
|
||||
|
||||
const optionsData: OptionsQuery = {
|
||||
...defaultOptionsQuery,
|
||||
@@ -311,7 +309,6 @@ const useOptionsMenu = ({
|
||||
},
|
||||
[
|
||||
searchedAttributeKeys,
|
||||
selectedColumnKeys,
|
||||
preferences,
|
||||
handleRedirectWithOptionsData,
|
||||
updateColumns,
|
||||
@@ -320,8 +317,9 @@ const useOptionsMenu = ({
|
||||
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
// columnKey is now the unique key (name::dataType::context)
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
({ name }) => name !== columnKey,
|
||||
(col) => getUniqueColumnKey(col) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -432,6 +430,7 @@ const useOptionsMenu = ({
|
||||
preferences?.columns.filter((item) => has(item, 'name')) ||
|
||||
defaultOptionsQuery.selectColumns.filter((item) => has(item, 'name')),
|
||||
options: optionsFromAttributeKeys || [],
|
||||
allAvailableKeys: searchedAttributeKeys,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onSelect: handleSelectColumns,
|
||||
@@ -455,6 +454,7 @@ const useOptionsMenu = ({
|
||||
isSearchedAttributesFetchingV5,
|
||||
preferences,
|
||||
optionsFromAttributeKeys,
|
||||
searchedAttributeKeys,
|
||||
handleSelectColumns,
|
||||
handleRemoveSelectedColumn,
|
||||
handleSearchAttribute,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
|
||||
export const getOptionsFromKeys = (
|
||||
keys: TelemetryFieldKey[],
|
||||
selectedKeys: (string | undefined)[],
|
||||
): SelectProps['options'] => {
|
||||
const options = keys.map(({ name }) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
return options.filter(
|
||||
({ value }) => !selectedKeys.find((key) => key === value),
|
||||
);
|
||||
};
|
||||
294
frontend/src/container/OptionsMenu/utils.tsx
Normal file
294
frontend/src/container/OptionsMenu/utils.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
QueryKeyDataSuggestionsProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
/**
|
||||
* Extracts all available keys from API response and transforms them into TelemetryFieldKey format
|
||||
* @param keysData - The response data from useGetQueryKeySuggestions hook
|
||||
* @returns Array of TelemetryFieldKey objects
|
||||
*/
|
||||
export const extractTelemetryFieldKeys = (
|
||||
keysData?: AxiosResponse<QueryKeySuggestionsResponseProps>,
|
||||
): TelemetryFieldKey[] => {
|
||||
const keysList = Object.values(keysData?.data?.data?.keys || {})?.flat() || [];
|
||||
return keysList.map((key) => ({
|
||||
name: key.name,
|
||||
fieldDataType: key.fieldDataType,
|
||||
fieldContext: key.fieldContext,
|
||||
signal: key.signal,
|
||||
})) as TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a unique key for a column by combining context, name, and dataType
|
||||
* Format: fieldContext::name::fieldDataType
|
||||
* Example: "attribute::http.status_code::number"
|
||||
*/
|
||||
export const getUniqueColumnKey = (
|
||||
column: TelemetryFieldKey | QueryKeyDataSuggestionsProps,
|
||||
): string => {
|
||||
const name = column.name || '';
|
||||
const dataType =
|
||||
('fieldDataType' in column && column.fieldDataType) ||
|
||||
('dataType' in column && column.dataType) ||
|
||||
'string';
|
||||
const context =
|
||||
column.fieldContext || ('type' in column && column.type) || 'attribute';
|
||||
return `${context}::${name}::${dataType}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a unique column key back into its components
|
||||
* Format: fieldContext::name::fieldDataType
|
||||
*/
|
||||
export const parseColumnKey = (
|
||||
key: string,
|
||||
): { name: string; fieldDataType: string; fieldContext: string } => {
|
||||
const parts = key.split('::');
|
||||
const fieldContext = parts[0] || 'attribute';
|
||||
const name = parts[1] || '';
|
||||
const fieldDataType = parts[2] || 'string';
|
||||
return { name, fieldDataType, fieldContext };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a count map of how many variants each attribute name has
|
||||
* Used to determine which columns should display badges
|
||||
*/
|
||||
export const getVariantCounts = <T extends { name?: string }>(
|
||||
items: T[],
|
||||
): Record<string, number> => {
|
||||
if (!items || !items.length) return {};
|
||||
return items.reduce((acc: Record<string, number>, item: T) => {
|
||||
const name = item?.name || '';
|
||||
if (name) {
|
||||
acc[name] = (acc[name] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts a Set of column names that have multiple variants from options
|
||||
* Useful when options already have hasMultipleVariants flag
|
||||
*/
|
||||
export const getNamesWithVariants = (
|
||||
options: SelectProps['options'],
|
||||
): Set<string> => {
|
||||
if (!options || !Array.isArray(options)) return new Set();
|
||||
const names = options
|
||||
.filter((opt) => {
|
||||
if (!opt) return false;
|
||||
const option = opt as DefaultOptionType & {
|
||||
hasMultipleVariants?: boolean;
|
||||
};
|
||||
return option?.hasMultipleVariants;
|
||||
})
|
||||
.map((opt) => {
|
||||
if (!opt) return '';
|
||||
const value = String(opt.value || '');
|
||||
return parseColumnKey(value).name;
|
||||
});
|
||||
return new Set(names);
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups fields by their name to analyze variants
|
||||
* Returns a map of field name to array of fields with that name
|
||||
*/
|
||||
export const getFieldVariantsByName = <T extends { name?: string }>(
|
||||
fields: T[],
|
||||
): Record<string, T[]> =>
|
||||
fields.reduce((acc, field) => {
|
||||
const name = field.name || '';
|
||||
if (!acc[name]) {
|
||||
acc[name] = [];
|
||||
}
|
||||
acc[name].push(field);
|
||||
return acc;
|
||||
}, {} as Record<string, T[]>);
|
||||
|
||||
/**
|
||||
* Determines the column title based on variant analysis
|
||||
* Shows context if dataTypes are same but contexts differ
|
||||
* Shows dataType if dataTypes differ
|
||||
*/
|
||||
export const getColumnTitle = <
|
||||
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
|
||||
>(
|
||||
field: T,
|
||||
hasVariants: boolean,
|
||||
variants: T[],
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): string => {
|
||||
const name = field.name || '';
|
||||
if (!hasVariants) return name;
|
||||
|
||||
// Extract data types from variants (support both fieldDataType and dataType)
|
||||
const uniqueDataTypes = new Set(
|
||||
variants
|
||||
.map(
|
||||
(v) =>
|
||||
('fieldDataType' in v && v.fieldDataType) ||
|
||||
('dataType' in v && v.dataType),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Extract contexts from variants (support both fieldContext and type)
|
||||
const uniqueContexts = new Set(
|
||||
variants
|
||||
.map(
|
||||
(v) => ('fieldContext' in v && v.fieldContext) || ('type' in v && v.type),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// Same dataType but different contexts - show context
|
||||
if (
|
||||
uniqueDataTypes.size === 1 &&
|
||||
uniqueContexts.size > 1 &&
|
||||
(field.fieldContext || ('type' in field && field.type))
|
||||
) {
|
||||
return `${name} (${field.fieldContext || ('type' in field && field.type)})`;
|
||||
}
|
||||
|
||||
// Different dataTypes - show dataType
|
||||
const dataType =
|
||||
('fieldDataType' in field && field.fieldDataType) ||
|
||||
('dataType' in field && field.dataType);
|
||||
if (dataType) {
|
||||
return `${name} (${dataType})`;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if another field with the same name but different unique key exists in availableKeys
|
||||
* and if any of those conflicting fields are NOT already selected
|
||||
* This indicates a conflicted column scenario where user might not be aware of other variants
|
||||
*/
|
||||
const hasUnselectedConflictingField = <
|
||||
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
|
||||
>(
|
||||
field: T,
|
||||
availableKeys?: TelemetryFieldKey[],
|
||||
selectedColumns?: TelemetryFieldKey[],
|
||||
): boolean => {
|
||||
if (!availableKeys || availableKeys.length === 0) return false;
|
||||
|
||||
const fieldName = field.name || '';
|
||||
const fieldUniqueKey = getUniqueColumnKey(field as TelemetryFieldKey);
|
||||
|
||||
// Find all conflicting fields (same name, different unique key)
|
||||
const conflictingFields = availableKeys.filter(
|
||||
(key) => key.name === fieldName && getUniqueColumnKey(key) !== fieldUniqueKey,
|
||||
);
|
||||
|
||||
// If no conflicting fields exist, no conflict
|
||||
if (conflictingFields.length === 0) return false;
|
||||
|
||||
// If no selected columns provided, assume conflict exists
|
||||
if (!selectedColumns || selectedColumns.length === 0) return true;
|
||||
|
||||
// Check if all conflicting fields are already selected
|
||||
const selectedUniqueKeys = new Set(
|
||||
selectedColumns.map((col) => getUniqueColumnKey(col)),
|
||||
);
|
||||
|
||||
// Return true if any conflicting field is NOT selected
|
||||
return conflictingFields.some(
|
||||
(conflictingField) =>
|
||||
!selectedUniqueKeys.has(getUniqueColumnKey(conflictingField)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns column title as string and metadata for tooltip icon
|
||||
* Shows tooltip only when another field with the same name but different type/context exists
|
||||
* and is NOT already selected (better UX - no need to show tooltip if all variants are visible)
|
||||
*
|
||||
* Returns an object with:
|
||||
* - title: string
|
||||
* - hasUnselectedConflict: boolean
|
||||
*/
|
||||
export const getColumnTitleWithTooltip = <
|
||||
T extends Partial<QueryKeyDataSuggestionsProps> | Partial<TelemetryFieldKey>
|
||||
>(
|
||||
field: T,
|
||||
hasVariants: boolean,
|
||||
variants: T[],
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
availableKeys?: TelemetryFieldKey[],
|
||||
): { title: string; hasUnselectedConflict: boolean } => {
|
||||
const title = getColumnTitle(field, hasVariants, variants);
|
||||
const hasUnselectedConflict = hasUnselectedConflictingField(
|
||||
field,
|
||||
availableKeys,
|
||||
selectedColumns,
|
||||
);
|
||||
|
||||
return { title, hasUnselectedConflict };
|
||||
};
|
||||
|
||||
export const getOptionsFromKeys = (
|
||||
keys: TelemetryFieldKey[],
|
||||
selectedKeys: (string | undefined)[],
|
||||
): SelectProps['options'] => {
|
||||
// Detect which attribute names have multiple variants
|
||||
const nameCounts = keys.reduce((acc, key) => {
|
||||
const name = key.name || '';
|
||||
acc[name] = (acc[name] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const options = keys.map((key) => ({
|
||||
label: key.name,
|
||||
value: getUniqueColumnKey(key),
|
||||
// Store additional data for rendering
|
||||
fieldDataType: key.fieldDataType,
|
||||
fieldContext: key.fieldContext,
|
||||
signal: key.signal,
|
||||
hasMultipleVariants: nameCounts[key.name || ''] > 1,
|
||||
}));
|
||||
|
||||
return options.filter(
|
||||
({ value }) => !selectedKeys.find((selectedKey) => selectedKey === value),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a column name has multiple variants
|
||||
* Checks both selected columns and available keys (from search) to detect conflicts
|
||||
* Reuses getVariantCounts for consistency
|
||||
*/
|
||||
export const hasMultipleVariants = (
|
||||
columnName: string,
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
availableKeys?: TelemetryFieldKey[],
|
||||
): boolean => {
|
||||
// Combine selected columns with available keys (if provided)
|
||||
const allKeys = availableKeys
|
||||
? [...selectedColumns, ...availableKeys]
|
||||
: selectedColumns;
|
||||
|
||||
// Deduplicate by unique key to avoid counting same variant twice
|
||||
const uniqueKeysMap = new Map<string, TelemetryFieldKey>();
|
||||
allKeys.forEach((key) => {
|
||||
const uniqueKey = getUniqueColumnKey(key);
|
||||
if (!uniqueKeysMap.has(uniqueKey)) {
|
||||
uniqueKeysMap.set(uniqueKey, key);
|
||||
}
|
||||
});
|
||||
|
||||
const deduplicatedKeys = Array.from(uniqueKeysMap.values());
|
||||
const variantCounts = getVariantCounts(deduplicatedKeys);
|
||||
|
||||
return variantCounts[columnName] > 1;
|
||||
};
|
||||
@@ -49,14 +49,12 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
>
|
||||
<span
|
||||
class="ant-typography value-graph-text css-dev-only-do-not-override-2i2tap"
|
||||
data-testid="value-graph-text"
|
||||
style="color: Blue; font-size: 16px;"
|
||||
>
|
||||
295.43
|
||||
</span>
|
||||
<span
|
||||
class="ant-typography value-graph-unit css-dev-only-do-not-override-2i2tap"
|
||||
data-testid="value-graph-suffix-unit"
|
||||
style="color: Blue; font-size: calc(16px * 0.7);"
|
||||
>
|
||||
ms
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Select } from 'antd';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
|
||||
interface SpaceAggregationOptionsProps {
|
||||
panelType: PANEL_TYPES | null;
|
||||
@@ -20,13 +22,39 @@ export default function SpaceAggregationOptions({
|
||||
operators,
|
||||
qbVersion,
|
||||
}: SpaceAggregationOptionsProps): JSX.Element {
|
||||
const placeHolderText =
|
||||
panelType === PANEL_TYPES.VALUE || qbVersion === 'v3' ? 'Sum' : 'Sum By';
|
||||
const [defaultValue, setDefaultValue] = useState(
|
||||
selectedValue || placeHolderText,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedValue) {
|
||||
if (
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.HISTOGRAM ||
|
||||
aggregatorAttributeType === ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM
|
||||
) {
|
||||
setDefaultValue(MetricAggregateOperator.P90);
|
||||
onSelect(MetricAggregateOperator.P90);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.SUM) {
|
||||
setDefaultValue(MetricAggregateOperator.SUM);
|
||||
onSelect(MetricAggregateOperator.SUM);
|
||||
} else if (aggregatorAttributeType === ATTRIBUTE_TYPES.GAUGE) {
|
||||
setDefaultValue(MetricAggregateOperator.AVG);
|
||||
onSelect(MetricAggregateOperator.AVG);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [aggregatorAttributeType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="spaceAggregationOptionsContainer"
|
||||
key={aggregatorAttributeType}
|
||||
>
|
||||
<Select
|
||||
defaultValue={selectedValue}
|
||||
defaultValue={defaultValue}
|
||||
style={{ minWidth: '5.625rem' }}
|
||||
disabled={disabled}
|
||||
onChange={onSelect}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ReduceToFilter } from './ReduceToFilter';
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
function baseQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
|
||||
return {
|
||||
dataSource: 'traces',
|
||||
aggregations: [],
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
having: { expression: '' },
|
||||
...overrides,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
describe('ReduceToFilter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with default avg when no reduceTo is set', () => {
|
||||
render(<ReduceToFilter query={baseQuery()} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId('reduce-to')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Average of values in timeframe'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.aggregations[0].reduceTo', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregations: [{ reduceTo: 'sum' } as any],
|
||||
aggregateAttribute: { key: 'test', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sum of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes from query.reduceTo when aggregations[0].reduceTo is not set', () => {
|
||||
render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
reduceTo: 'max',
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Max of values in timeframe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates to sum when aggregateAttribute.type is SUM', async () => {
|
||||
const { rerender } = render(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test', type: MetricType.GAUGE },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ReduceToFilter
|
||||
query={baseQuery({
|
||||
aggregateAttribute: { key: 'test2', type: MetricType.SUM },
|
||||
})}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reduceToFilterText = (await screen.findByText(
|
||||
'Sum of values in timeframe',
|
||||
)) as HTMLElement;
|
||||
expect(reduceToFilterText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Select } from 'antd';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { REDUCE_TO_VALUES } from 'constants/queryBuilder';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
// ** Types
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -13,46 +12,19 @@ export const ReduceToFilter = memo(function ReduceToFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: ReduceToFilterProps): JSX.Element {
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [currentValue, setCurrentValue] = useState<
|
||||
SelectOption<ReduceOperators, string>
|
||||
>(REDUCE_TO_VALUES[2]); // default to avg
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
const currentValue =
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[0];
|
||||
|
||||
const handleChange = (
|
||||
newValue: SelectOption<ReduceOperators, string>,
|
||||
): void => {
|
||||
setCurrentValue(newValue);
|
||||
onChange(newValue.value);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isMounted.current) {
|
||||
const reduceToValue =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.reduceTo || query.reduceTo;
|
||||
|
||||
setCurrentValue(
|
||||
REDUCE_TO_VALUES.find((option) => option.value === reduceToValue) ||
|
||||
REDUCE_TO_VALUES[2],
|
||||
);
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const aggregationAttributeType = query.aggregateAttribute?.type as
|
||||
| MetricType
|
||||
| undefined;
|
||||
|
||||
if (aggregationAttributeType === MetricType.SUM) {
|
||||
handleChange(REDUCE_TO_VALUES[1]);
|
||||
} else {
|
||||
handleChange(REDUCE_TO_VALUES[2]);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[query.aggregateAttribute?.key],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder="Reduce to"
|
||||
|
||||
@@ -363,6 +363,7 @@ export const WidgetHeaderProps: any = {
|
||||
title: 'Table - Panel',
|
||||
yAxisUnit: 'none',
|
||||
},
|
||||
parentHover: false,
|
||||
queryResponse: {
|
||||
status: 'success',
|
||||
isLoading: false,
|
||||
|
||||
@@ -68,7 +68,6 @@ import { USER_ROLES } from 'types/roles';
|
||||
import { checkVersionState } from 'utils/app';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { routeConfig } from './config';
|
||||
import { getQueryString } from './helper';
|
||||
import {
|
||||
@@ -121,7 +120,6 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const { openCmdK } = useCmdK();
|
||||
const { pathname, search } = useLocation();
|
||||
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
|
||||
AppState,
|
||||
@@ -639,8 +637,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
} else {
|
||||
history.push(settingsRoute);
|
||||
}
|
||||
} else if (item.key === 'quick-search') {
|
||||
openCmdK();
|
||||
} else if (item) {
|
||||
onClickHandler(item?.key as string, event);
|
||||
}
|
||||
@@ -679,42 +675,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToExceptions, () =>
|
||||
onClickHandler(ROUTES.ALL_ERROR, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesFunnel, () =>
|
||||
onClickHandler(ROUTES.TRACES_FUNNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesViews, () =>
|
||||
onClickHandler(ROUTES.TRACES_SAVE_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsSummary, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsExplorer, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsViews, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettings, () =>
|
||||
onClickHandler(ROUTES.SETTINGS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsIngestion, () =>
|
||||
onClickHandler(ROUTES.INGESTION_SETTINGS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys, () =>
|
||||
onClickHandler(ROUTES.API_KEYS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
|
||||
onClickHandler(ROUTES.LOGS_PIPELINES, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsViews, () =>
|
||||
onClickHandler(ROUTES.LOGS_SAVE_VIEWS, null),
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToHome);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToServices);
|
||||
@@ -724,18 +685,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToAlerts);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToExceptions);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMessagingQueues);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesFunnel);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsSummary);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsExplorer);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsAPIKeys);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);
|
||||
};
|
||||
}, [deregisterShortcut, onClickHandler, registerShortcut]);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
Receipt,
|
||||
Route,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Slack,
|
||||
Unplug,
|
||||
@@ -189,12 +188,6 @@ export const primaryMenuItems: SidebarItem[] = [
|
||||
icon: <Home size={16} />,
|
||||
itemKey: 'home',
|
||||
},
|
||||
{
|
||||
key: 'quick-search',
|
||||
label: 'Search',
|
||||
icon: <Search size={16} />,
|
||||
itemKey: 'quick-search',
|
||||
},
|
||||
{
|
||||
key: ROUTES.LIST_ALL_ALERT,
|
||||
label: 'Alerts',
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Tooltip, Typography } from 'antd';
|
||||
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
|
||||
|
||||
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
|
||||
const ATTRIBUTE_LENGTH_THRESHOLD = 100;
|
||||
|
||||
interface EventAttributeProps {
|
||||
attributeKey: string;
|
||||
@@ -16,11 +15,7 @@ function EventAttribute({
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: EventAttributeProps): JSX.Element {
|
||||
const shouldExpand =
|
||||
EXPANDABLE_ATTRIBUTE_KEYS.includes(attributeKey) ||
|
||||
attributeValue.length > ATTRIBUTE_LENGTH_THRESHOLD;
|
||||
|
||||
if (shouldExpand) {
|
||||
if (EXPANDABLE_ATTRIBUTE_KEYS.includes(attributeKey)) {
|
||||
return (
|
||||
<AttributeWithExpandablePopover
|
||||
attributeKey={attributeKey}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user