diff --git a/.devenv/docker/clickhouse/compose.yaml b/.devenv/docker/clickhouse/compose.yaml index 0a6dd33d9aed..4e426b83e550 100644 --- a/.devenv/docker/clickhouse/compose.yaml +++ b/.devenv/docker/clickhouse/compose.yaml @@ -42,7 +42,7 @@ services: timeout: 5s retries: 3 schema-migrator-sync: - image: signoz/signoz-schema-migrator:v0.129.4 + image: signoz/signoz-schema-migrator:v0.129.5 container_name: schema-migrator-sync command: - sync @@ -55,7 +55,7 @@ services: condition: service_healthy restart: on-failure schema-migrator-async: - image: signoz/signoz-schema-migrator:v0.129.4 + image: signoz/signoz-schema-migrator:v0.129.5 container_name: schema-migrator-async command: - async diff --git a/.golangci.yml b/.golangci.yml index c2d325f5e84c..00643925fdef 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,6 +8,7 @@ linters: - depguard - iface - unparam + - forbidigo linters-settings: sloglint: @@ -24,6 +25,10 @@ linters-settings: deny: - pkg: "go.uber.org/zap" desc: "Do not use zap logger. Use slog instead." + noerrors: + deny: + - pkg: "errors" + desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead." iface: enable: - identical diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a2a85dc0dd5..8f49cccf6063 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,4 +78,5 @@ Need assistance? Join our Slack community: - Set up your [development environment](docs/contributing/development.md) - Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo-docs.md) -- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future. \ No newline at end of file +- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future. +- Write [integration tests](docs/contributing/go/integration.md) diff --git a/cmd/community/server.go b/cmd/community/server.go index 9def8a147ace..a437b450c172 100644 --- a/cmd/community/server.go +++ b/cmd/community/server.go @@ -32,7 +32,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { Short: "Run the SigNoz server", FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(currCmd *cobra.Command, args []string) error { - config, err := cmd.NewSigNozConfig(currCmd.Context(), flags) + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) if err != nil { return err } diff --git a/cmd/config.go b/cmd/config.go index 4e627c4f91d1..206d9b44d4c0 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "log/slog" "os" @@ -12,9 +11,10 @@ import ( "github.com/SigNoz/signoz/pkg/signoz" ) -func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz.Config, error) { +func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) { config, err := signoz.NewConfig( ctx, + logger, config.ResolverConfig{ Uris: []string{"env:"}, ProviderFactories: []config.ProviderFactory{ @@ -31,14 +31,10 @@ func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz. return config, nil } -func NewJWTSecret(_ context.Context, _ *slog.Logger) string { +func NewJWTSecret(ctx context.Context, logger *slog.Logger) string { jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") if len(jwtSecret) == 0 { - fmt.Println("🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!") - fmt.Println("SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application.") - fmt.Println("Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access.") - fmt.Println("Please set the SIGNOZ_JWT_SECRET environment variable immediately.") - fmt.Println("For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.") + logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.") } return jwtSecret diff --git a/cmd/enterprise/server.go b/cmd/enterprise/server.go index 56344ea8b2eb..b513e9a744b2 100644 --- a/cmd/enterprise/server.go +++ b/cmd/enterprise/server.go @@ -35,7 +35,7 @@ func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { Short: "Run the SigNoz server", FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, RunE: func(currCmd *cobra.Command, args []string) error { - config, err := cmd.NewSigNozConfig(currCmd.Context(), flags) + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) if err != nil { return err } diff --git a/conf/example.yaml b/conf/example.yaml index 7fd1fb9e976c..d22fa37cab0e 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -137,10 +137,7 @@ prometheus: ##################### Alertmanager ##################### alertmanager: # Specifies the alertmanager provider to use. - provider: legacy - legacy: - # The API URL (with prefix) of the legacy Alertmanager instance. - api_url: http://localhost:9093/api + provider: signoz signoz: # The poll interval for periodically syncing the alertmanager with the config in the store. poll_interval: 1m diff --git a/deploy/docker-swarm/docker-compose.ha.yaml b/deploy/docker-swarm/docker-compose.ha.yaml index e5396e63d24f..470f6071b0e5 100644 --- a/deploy/docker-swarm/docker-compose.ha.yaml +++ b/deploy/docker-swarm/docker-compose.ha.yaml @@ -176,7 +176,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.94.0 + image: signoz/signoz:v0.95.0 command: - --config=/root/config/prometheus.yml ports: @@ -209,7 +209,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.129.4 + image: signoz/signoz-otel-collector:v0.129.5 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml @@ -233,7 +233,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.129.4 + image: signoz/signoz-schema-migrator:v0.129.5 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker-swarm/docker-compose.yaml b/deploy/docker-swarm/docker-compose.yaml index 74fe91c55cb1..3c9c499616d0 100644 --- a/deploy/docker-swarm/docker-compose.yaml +++ b/deploy/docker-swarm/docker-compose.yaml @@ -117,7 +117,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.94.0 + image: signoz/signoz:v0.95.0 command: - --config=/root/config/prometheus.yml ports: @@ -150,7 +150,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.129.4 + image: signoz/signoz-otel-collector:v0.129.5 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml @@ -176,7 +176,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.129.4 + image: signoz/signoz-schema-migrator:v0.129.5 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker/docker-compose.ha.yaml b/deploy/docker/docker-compose.ha.yaml index 9c6298c937f2..a9ca35445ea5 100644 --- a/deploy/docker/docker-compose.ha.yaml +++ b/deploy/docker/docker-compose.ha.yaml @@ -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.94.0} + image: signoz/signoz:${VERSION:-v0.95.0} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -213,7 +213,7 @@ services: # TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing? otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -239,7 +239,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5} container_name: schema-migrator-sync command: - sync @@ -250,7 +250,7 @@ services: condition: service_healthy schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5} container_name: schema-migrator-async command: - async diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index e3a856f1a924..8e2410c10210 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -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.94.0} + image: signoz/signoz:${VERSION:-v0.95.0} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -144,7 +144,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -166,7 +166,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5} container_name: schema-migrator-sync command: - sync @@ -178,7 +178,7 @@ services: restart: on-failure schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5} container_name: schema-migrator-async command: - async diff --git a/docs/contributing/go/integration.md b/docs/contributing/go/integration.md new file mode 100644 index 000000000000..6e1074e6126b --- /dev/null +++ b/docs/contributing/go/integration.md @@ -0,0 +1,213 @@ +# Integration Tests + +SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality. + +## How to set up the integration test environment? + +### Prerequisites + +Before running integration tests, ensure you have the following installed: + +- Python 3.13+ +- Poetry (for dependency management) +- Docker (for containerized services) + +### Initial Setup + +1. Navigate to the integration tests directory: +```bash +cd tests/integration +``` + +2. Install dependencies using Poetry: +```bash +poetry install --no-root +``` + +### Starting the Test Environment + +To spin up all the containers necessary for writing integration tests and keep them running: + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup +``` + +This command will: +- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.) +- Keep containers running due to the `--reuse` flag +- Verify that the setup is working correctly + +### Stopping the Test Environment + +When you're done writing integration tests, clean up the environment: + +```bash +poetry run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown +``` + +This will destroy the running integration test setup and clean up resources. + +## Understanding the Integration Test Framework + +Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services. + +- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data +- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup. +- **Why wiremock?** Well maintained, documented and extensible. + +``` +. +├── conftest.py +├── fixtures +│ ├── __init__.py +│ ├── auth.py +│ ├── clickhouse.py +│ ├── fs.py +│ ├── http.py +│ ├── migrator.py +│ ├── network.py +│ ├── postgres.py +│ ├── signoz.py +│ ├── sql.py +│ ├── sqlite.py +│ ├── types.py +│ └── zookeeper.py +├── poetry.lock +├── pyproject.toml +└── src + └── bootstrap + ├── __init__.py + ├── a_database.py + ├── b_register.py + └── c_license.py +``` + +Each test suite follows some important principles: + +1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`. +2. **Execution Order**: Files are prefixed with `a_`, `b_`, `c_` to ensure sequential execution. +3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins). + +### Test Suite Design + +Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles: + +- **Functional Cohesion**: Group tests around a specific capability or service boundary +- **Data Flow**: Follow the path of data through related components +- **Change Patterns**: Components frequently modified together should be tested together + +The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system. + +Eg: The **bootstrap** integration test suite validates core system functionality: + +- Database initialization +- Version check + +Other test suites can be **pipelines, auth, querier.** + +## How to write an integration test? + +Now start writing an integration test. Create a new file `src/bootstrap/e_version.py` and paste the following: + +```python +import requests + +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +def test_version(signoz: types.SigNoz) -> None: + response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2) + logger.info(response) +``` + +We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:** + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/e_version.py::test_version +``` + +> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test. + +Here's another example of how to write a more comprehensive integration test: + +```python +from http import HTTPStatus +import requests +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +def test_user_registration(signoz: types.SigNoz) -> None: + """Test user registration functionality.""" + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/register"), + json={ + "name": "testuser", + "orgId": "", + "orgName": "test.org", + "email": "test@example.com", + "password": "password123Z$", + }, + timeout=2, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()["setupCompleted"] is True +``` + +## How to run integration tests? + +### Running All Tests + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/ +``` + +### Running Specific Test Categories + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/ + +# Run querier tests +poetry run pytest --basetemp=./tmp/ -vv --reuse src/querier/ +# Run auth tests +poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/ +``` + +### Running Individual Tests + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src//.py::test_name + +# Run test_register in file a_register.py in auth suite +poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/a_register.py::test_register +``` + +## How to configure different options for integration tests? + +Tests can be configured using pytest options: + +- `--sqlstore-provider` - Choose database provider (default: postgres) +- `--postgres-version` - PostgreSQL version (default: 15) +- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine) +- `--zookeeper-version` - Zookeeper version (default: 3.7.1) + +Example: +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/ +``` + + +## What should I remember? + +- **Always use the `--reuse` flag** when setting up the environment to keep containers running +- **Use the `--teardown` flag** when cleaning up to avoid resource leaks +- **Follow the naming convention** with alphabetical prefixes for test execution order +- **Use proper timeouts** in HTTP requests to avoid hanging tests +- **Clean up test data** between tests to avoid interference +- **Use descriptive test names** that clearly indicate what is being tested +- **Leverage fixtures** for common setup and authentication +- **Test both success and failure scenarios** to ensure robust functionality diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga new file mode 100644 index 000000000000..17cbaec7d87d --- /dev/null +++ b/ee/authz/openfgaschema/base.fga @@ -0,0 +1,44 @@ +module base + +type organisation + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + +type user + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type anonymous + +type role + relations + define assignee: [user] + + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type resources + relations + define create: [user, role#assignee] + define list: [user, role#assignee] + + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type resource + relations + define read: [user, anonymous, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + + define block: [user, role#assignee] + + +type telemetry + relations + define read: [user, anonymous, role#assignee] diff --git a/ee/authz/openfgaschema/schema.go b/ee/authz/openfgaschema/schema.go new file mode 100644 index 000000000000..605cad0501f1 --- /dev/null +++ b/ee/authz/openfgaschema/schema.go @@ -0,0 +1,29 @@ +package openfgaschema + +import ( + "context" + _ "embed" + + "github.com/SigNoz/signoz/pkg/authz" + openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer" +) + +var ( + //go:embed base.fga + baseDSL string +) + +type schema struct{} + +func NewSchema() authz.Schema { + return &schema{} +} + +func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile { + return []openfgapkgtransformer.ModuleFile{ + { + Name: "base.fga", + Contents: baseDSL, + }, + } +} diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go new file mode 100644 index 000000000000..c6b20eda39c1 --- /dev/null +++ b/ee/http/middleware/authz.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "log/slog" + "net/http" + + "github.com/SigNoz/signoz/pkg/authz" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/gorilla/mux" +) + +const ( + authzDeniedMessage string = "::AUTHZ-DENIED::" +) + +type AuthZ struct { + logger *slog.Logger + authzService authz.AuthZ +} + +func NewAuthZ(logger *slog.Logger) *AuthZ { + if logger == nil { + panic("cannot build authz middleware, logger is empty") + } + + return &AuthZ{logger: logger} +} + +func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsViewer(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsEditor(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsAdmin(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + id := mux.Vars(req)["id"] + if err := claims.IsSelfAccess(id); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + next(rw, req) + }) +} + +// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis. +func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + selector, parentSelectors, err := cb(req) + if err != nil { + render.Error(rw, err) + return + } + + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...) + if err != nil { + render.Error(rw, err) + return + } + + next(rw, req) + }) +} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index fc516f49e95a..a963cf4e33f2 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -8,6 +8,8 @@ import ( "net/http" _ "net/http/pprof" // http profiler + "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" + "github.com/gorilla/handlers" "github.com/SigNoz/signoz/ee/query-service/app/api" @@ -44,19 +46,6 @@ import ( "go.uber.org/zap" ) -type ServerOptions struct { - Config signoz.Config - SigNoz *signoz.SigNoz - HTTPHostPort string - PrivateHostPort string - PreferSpanMetrics bool - FluxInterval string - FluxIntervalForTraceDetail string - Cluster string - GatewayUrl string - Jwt *authtypes.JWT -} - // Server runs HTTP, Mux and a grpc server type Server struct { config signoz.Config @@ -69,11 +58,6 @@ type Server struct { httpServer *http.Server httpHostPort string - // private http - privateConn net.Listener - privateHTTP *http.Server - privateHostPort string - opampServer *opamp.Server // Usage manager @@ -183,7 +167,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) jwt: jwt, ruleManager: rm, httpHostPort: baseconst.HTTPHostPort, - privateHostPort: baseconst.PrivateHostPort, unavailableChannel: make(chan healthcheck.Status), usageManager: usageManager, } @@ -196,13 +179,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) s.httpServer = httpServer - privateServer, err := s.createPrivateServer(apiHandler) - if err != nil { - return nil, err - } - - s.privateHTTP = privateServer - s.opampServer = opamp.InitializeServer( &opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation, ) @@ -215,36 +191,6 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status { return s.unavailableChannel } -func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, error) { - r := baseapp.NewRouter() - - r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap) - r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap) - r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(), - s.config.APIServer.Timeout.ExcludedRoutes, - s.config.APIServer.Timeout.Default, - s.config.APIServer.Timeout.Max, - ).Wrap) - r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap) - - apiHandler.RegisterPrivateRoutes(r) - - c := cors.New(cors.Options{ - //todo(amol): find out a way to add exact domain or - // ip here for alert manager - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"}, - }) - - handler := c.Handler(r) - handler = handlers.CompressHandler(handler) - - return &http.Server{ - Handler: handler, - }, nil -} - func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) { r := baseapp.NewRouter() am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger()) @@ -310,19 +256,6 @@ func (s *Server) initListeners() error { zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort)) - // listen on private port to support internal services - privateHostPort := s.privateHostPort - - if privateHostPort == "" { - return fmt.Errorf("baseconst.PrivateHostPort is required") - } - - s.privateConn, err = net.Listen("tcp", privateHostPort) - if err != nil { - return err - } - zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.privateHostPort)) - return nil } @@ -361,26 +294,6 @@ func (s *Server) Start(ctx context.Context) error { } }() - var privatePort int - if port, err := utils.GetPort(s.privateConn.Addr()); err == nil { - privatePort = port - } - - go func() { - zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.privateHostPort)) - - switch err := s.privateHTTP.Serve(s.privateConn); err { - case nil, http.ErrServerClosed, cmux.ErrListenerClosed: - // normal exit, nothing to do - zap.L().Info("private http server closed") - default: - zap.L().Error("Could not start private HTTP server", zap.Error(err)) - } - - s.unavailableChannel <- healthcheck.Unavailable - - }() - go func() { zap.L().Info("Starting OpAmp Websocket server", zap.String("addr", baseconst.OpAmpWsEndpoint)) err := s.opampServer.Start(baseconst.OpAmpWsEndpoint) @@ -400,12 +313,6 @@ func (s *Server) Stop(ctx context.Context) error { } } - if s.privateHTTP != nil { - if err := s.privateHTTP.Shutdown(ctx); err != nil { - return err - } - } - s.opampServer.Stop() if s.ruleManager != nil { @@ -429,6 +336,8 @@ func makeRulesManager( querier querier.Querier, logger *slog.Logger, ) (*baserules.Manager, error) { + ruleStore := sqlrulestore.NewRuleStore(sqlstore) + maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore) // create manager opts managerOpts := &baserules.ManagerOptions{ TelemetryStore: telemetryStore, @@ -443,8 +352,10 @@ func makeRulesManager( PrepareTaskFunc: rules.PrepareTaskFunc, PrepareTestRuleFunc: rules.TestNotification, Alertmanager: alertmanager, - SQLStore: sqlstore, OrgGetter: orgGetter, + RuleStore: ruleStore, + MaintenanceStore: maintenanceStore, + SqlStore: sqlstore, } // create Manager diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index 31653369c6fb..b7c6d11fc58b 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -40,7 +40,7 @@ var IsDotMetricsEnabled = false var IsPreferSpanMetrics = false func init() { - if GetOrDefaultEnv(DotMetricsEnabled, "false") == "true" { + if GetOrDefaultEnv(DotMetricsEnabled, "true") == "true" { IsDotMetricsEnabled = true } diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index 3fbf1e32b1f2..2ac3b56cb949 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -35,7 +35,6 @@ import ( anomalyV2 "github.com/SigNoz/signoz/ee/anomaly" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" - yaml "gopkg.in/yaml.v2" ) const ( @@ -167,16 +166,9 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3. ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(), ) - start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli() - end := ts.UnixMilli() - - if r.EvalDelay() > 0 { - start = start - int64(r.EvalDelay().Milliseconds()) - end = end - int64(r.EvalDelay().Milliseconds()) - } - // round to minute otherwise we could potentially miss data - start = start - (start % (60 * 1000)) - end = end - (end % (60 * 1000)) + st, en := r.Timestamps(ts) + start := st.UnixMilli() + end := en.UnixMilli() compositeQuery := r.Condition().CompositeQuery @@ -253,10 +245,17 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON)) for _, series := range queryResult.AnomalyScores { - smpl, shouldAlert := r.ShouldAlert(*series) - if shouldAlert { - resultVector = append(resultVector, smpl) + if r.Condition() != nil && r.Condition().RequireMinPoints { + if len(series.Points) < r.Condition().RequiredNumPoints { + r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints) + continue + } } + results, err := r.Threshold.ShouldAlert(*series) + if err != nil { + return nil, err + } + resultVector = append(resultVector, results...) } return resultVector, nil } @@ -296,10 +295,17 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON)) for _, series := range queryResult.AnomalyScores { - smpl, shouldAlert := r.ShouldAlert(*series) - if shouldAlert { - resultVector = append(resultVector, smpl) + if r.Condition().RequireMinPoints { + if len(series.Points) < r.Condition().RequiredNumPoints { + r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints) + continue + } } + results, err := r.Threshold.ShouldAlert(*series) + if err != nil { + return nil, err + } + resultVector = append(resultVector, results...) } return resultVector, nil } @@ -499,7 +505,7 @@ func (r *AnomalyRule) String() string { PreferredChannels: r.PreferredChannels(), } - byt, err := yaml.Marshal(ar) + byt, err := json.Marshal(ar) if err != nil { return fmt.Sprintf("error marshaling alerting rule: %s", err.Error()) } diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go index bf5cbbbec117..3212031f9f3f 100644 --- a/ee/query-service/rules/manager.go +++ b/ee/query-service/rules/manager.go @@ -3,8 +3,10 @@ package rules import ( "context" "fmt" + "time" + "github.com/SigNoz/signoz/pkg/errors" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" baserules "github.com/SigNoz/signoz/pkg/query-service/rules" "github.com/SigNoz/signoz/pkg/query-service/utils/labels" @@ -20,6 +22,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) var task baserules.Task ruleId := baserules.RuleIdFromTaskName(opts.TaskName) + evaluation, err := opts.Rule.Evaluation.GetEvaluation() + if err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err) + } if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { // create a threshold rule tr, err := baserules.NewThresholdRule( @@ -40,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, tr) // create ch rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeProm { @@ -62,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, pr) // create promql rule task for evalution - task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly { // create anomaly rule @@ -84,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, ar) // create anomaly rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else { return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) diff --git a/frontend/.cursorrules b/frontend/.cursorrules new file mode 100644 index 000000000000..9cfa908ba60f --- /dev/null +++ b/frontend/.cursorrules @@ -0,0 +1,484 @@ +# Persona +You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository. + +# Auto-detect TypeScript Usage +Check for TypeScript in the project through tsconfig.json or package.json dependencies. +Adjust syntax based on this detection. + +# TypeScript Type Safety for Jest Tests +**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types. + +**Type Safety Requirements:** +- Use proper TypeScript interfaces for all mock data +- Type all Jest mock functions with `jest.MockedFunction` +- Use generic types for React components and hooks +- Define proper return types for mock functions +- Use `as const` for literal types when needed +- Avoid `any` type – use proper typing instead + +# Unit Testing Focus +Focus on critical functionality (business logic, utility functions, component behavior) +Mock dependencies (API calls, external modules) before imports +Test multiple data scenarios (valid inputs, invalid inputs, edge cases) +Write maintainable tests with descriptive names grouped in describe blocks + +# Global vs Local Mocks +**Use Global Mocks for:** +- High-frequency dependencies (20+ test files) +- Core infrastructure (react-router-dom, react-query, antd) +- Standard implementations across the app +- Browser APIs (ResizeObserver, matchMedia, localStorage) +- Utility libraries (date-fns, lodash) + +**Use Local Mocks for:** +- Business logic dependencies (5-15 test files) +- Test-specific behavior (different data per test) +- API endpoints with specific responses +- Domain-specific components +- Error scenarios and edge cases + +**Global Mock Files Available (from jest.config.ts):** +- `uplot` → `__mocks__/uplotMock.ts` + +# Repo-specific Testing Conventions + +## Imports +Always import from our harness: +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +``` +For API mocks: +```ts +import { server, rest } from 'mocks-server/server'; +``` +Do not import directly from `@testing-library/react`. + +## Router +Use the router built into render: +```ts +render(, undefined, { initialRoute: '/traces-explorer' }); +``` +Only mock `useLocation` / `useParams` if the test depends on them. + +## Hook Mocks +Pattern: +```ts +import useFoo from 'hooks/useFoo'; +jest.mock('hooks/useFoo'); +const mockUseFoo = jest.mocked(useFoo); +mockUseFoo.mockReturnValue(/* minimal shape */ as any); +``` +Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results. + +## MSW +Global MSW server runs automatically. +Override per-test: +```ts +server.use( + rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true }))) +); +``` +Keep large responses in `mocks-server/__mockdata_`. + +## Interactions +- Prefer `userEvent` for real user interactions (click, type, select, tab). +- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed. +- Always await interactions: +```ts +const user = userEvent.setup({ pointerEventsCheck: 0 }); +await user.click(screen.getByRole('button', { name: /save/i })); +``` + +```ts +// Example: virtualized list scroll (no userEvent helper) +const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement; +scroller.scrollTop = targetScrollTop; +act(() => { fireEvent.scroll(scroller); }); +``` + +## Timers +❌ No global fake timers. +✅ Per-test only, for debounce/throttle: +```ts +jest.useFakeTimers(); +const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) }); +await user.type(screen.getByRole('textbox'), 'query'); +jest.advanceTimersByTime(400); +jest.useRealTimers(); +``` + +## Queries +Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`). +Fallback: visible text. +Last resort: `data-testid`. + +# Example Test (using only configured global mocks) +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import { server, rest } from 'mocks-server/server'; +import MyComponent from '../MyComponent'; + +describe('MyComponent', () => { + it('renders and interacts', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 }))) + ); + + render(, undefined, { initialRoute: '/foo' }); + + expect(await screen.findByText(/value: 42/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /refresh/i })); + await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument()); + }); +}); +``` + +# Anti-patterns +❌ Importing RTL directly +❌ Using global fake timers +❌ Wrapping render in `act(...)` +❌ Mocking infra dependencies locally (router, react-query) +✅ Use our harness (`tests/test-utils`) +✅ Use MSW for API overrides +✅ Use userEvent + await +✅ Pin time only in tests that assert relative dates + +# Best Practices +- **Critical Functionality**: Prioritize testing business logic and utilities +- **Dependency Mocking**: Global mocks for infra, local mocks for business logic +- **Data Scenarios**: Always test valid, invalid, and edge cases +- **Descriptive Names**: Make test intent clear +- **Organization**: Group related tests in describe +- **Consistency**: Match repo conventions +- **Edge Cases**: Test null, undefined, unexpected values +- **Limit Scope**: 3–5 focused tests per file +- **Use Helpers**: `rqSuccess`, `makeUser`, etc. +- **No Any**: Enforce type safety + +# Example Test +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import { server, rest } from 'mocks-server/server'; +import MyComponent from '../MyComponent'; + +describe('MyComponent', () => { + it('renders and interacts', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 }))) + ); + + render(, undefined, { initialRoute: '/foo' }); + + expect(await screen.findByText(/value: 42/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /refresh/i })); + await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument()); + }); +}); +``` + +# Anti-patterns +❌ Importing RTL directly +❌ Using global fake timers +❌ Wrapping render in `act(...)` +❌ Mocking infra dependencies locally (router, react-query) +✅ Use our harness (`tests/test-utils`) +✅ Use MSW for API overrides +✅ Use userEvent + await +✅ Pin time only in tests that assert relative dates + +# TypeScript Type Safety Examples + +## Proper Mock Typing +```ts +// ✅ GOOD - Properly typed mocks +interface User { + id: number; + name: string; + email: string; +} + +interface ApiResponse { + data: T; + status: number; + message: string; +} + +// Type the mock functions +const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise>>; +const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise>>; + +// Mock implementation with proper typing +mockFetchUser.mockResolvedValue({ + data: { id: 1, name: 'John Doe', email: 'john@example.com' }, + status: 200, + message: 'Success' +}); + +// ❌ BAD - Using any type +const mockFetchUser = jest.fn() as any; // Don't do this +``` + +## React Component Testing with Types +```ts +// ✅ GOOD - Properly typed component testing +interface ComponentProps { + title: string; + data: User[]; + onUserSelect: (user: User) => void; + isLoading?: boolean; +} + +const TestComponent: React.FC = ({ title, data, onUserSelect, isLoading = false }) => { + // Component implementation +}; + +describe('TestComponent', () => { + it('should render with proper props', () => { + // Arrange - Type the props properly + const mockProps: ComponentProps = { + title: 'Test Title', + data: [{ id: 1, name: 'John', email: 'john@example.com' }], + onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>, + isLoading: false + }; + + // Act + render(); + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); +}); +``` + +## Hook Testing with Types +```ts +// ✅ GOOD - Properly typed hook testing +interface UseUserDataReturn { + user: User | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +const useUserData = (id: number): UseUserDataReturn => { + // Hook implementation +}; + +describe('useUserData', () => { + it('should return user data with proper typing', () => { + // Arrange + const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' }; + mockFetchUser.mockResolvedValue({ + data: mockUser, + status: 200, + message: 'Success' + }); + + // Act + const { result } = renderHook(() => useUserData(1)); + + // Assert + expect(result.current.user).toEqual(mockUser); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); +``` + +## Global Mock Type Safety +```ts +// ✅ GOOD - Type-safe global mocks +// In __mocks__/routerMock.ts +export const mockUseLocation = (overrides: Partial = {}): Location => ({ + pathname: '/traces', + search: '', + hash: '', + state: null, + key: 'test-key', + ...overrides, +}); + +// In test files +const location = useLocation(); // Properly typed from global mock +expect(location.pathname).toBe('/traces'); +``` + +# TypeScript Configuration for Jest + +## Required Jest Configuration +```json +// jest.config.ts +{ + "preset": "ts-jest/presets/js-with-ts-esm", + "globals": { + "ts-jest": { + "useESM": true, + "isolatedModules": true, + "tsconfig": "/tsconfig.jest.json" + } + }, + "extensionsToTreatAsEsm": [".ts", ".tsx"], + "moduleFileExtensions": ["ts", "tsx", "js", "json"] +} +``` + +## TypeScript Jest Configuration +```json +// tsconfig.jest.json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "@testing-library/jest-dom"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node" + }, + "include": [ + "src/**/*", + "**/*.test.ts", + "**/*.test.tsx", + "__mocks__/**/*" + ] +} +``` + +## Common Type Safety Patterns + +### Mock Function Typing +```ts +// ✅ GOOD - Proper mock function typing +const mockApiCall = jest.fn() as jest.MockedFunction; +const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>; + +// ❌ BAD - Using any +const mockApiCall = jest.fn() as any; +``` + +### Generic Mock Typing +```ts +// ✅ GOOD - Generic mock typing +interface MockApiResponse { + data: T; + status: number; +} + +const mockFetchData = jest.fn() as jest.MockedFunction< + (endpoint: string) => Promise> +>; + +// Usage +mockFetchData('/users').mockResolvedValue({ + data: { id: 1, name: 'John' }, + status: 200 +}); +``` + +### React Testing Library with Types +```ts +// ✅ GOOD - Typed testing utilities +import { render, screen, RenderResult } from '@testing-library/react'; +import { ComponentProps } from 'react'; + +type TestComponentProps = ComponentProps; + +const renderTestComponent = (props: Partial = {}): RenderResult => { + const defaultProps: TestComponentProps = { + title: 'Test', + data: [], + onSelect: jest.fn(), + ...props + }; + + return render(); +}; +``` + +### Error Handling with Types +```ts +// ✅ GOOD - Typed error handling +interface ApiError { + message: string; + code: number; + details?: Record; +} + +const mockApiError: ApiError = { + message: 'API Error', + code: 500, + details: { endpoint: '/users' } +}; + +mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError))); +``` + +## Type Safety Checklist +- [ ] All mock functions use `jest.MockedFunction` +- [ ] All mock data has proper interfaces +- [ ] No `any` types in test files +- [ ] Generic types are used where appropriate +- [ ] Error types are properly defined +- [ ] Component props are typed +- [ ] Hook return types are defined +- [ ] API response types are defined +- [ ] Global mocks are type-safe +- [ ] Test utilities are properly typed + +# Mock Decision Tree +``` +Is it used in 20+ test files? +├─ YES → Use Global Mock +│ ├─ react-router-dom +│ ├─ react-query +│ ├─ antd components +│ └─ browser APIs +│ +└─ NO → Is it business logic? + ├─ YES → Use Local Mock + │ ├─ API endpoints + │ ├─ Custom hooks + │ └─ Domain components + │ + └─ NO → Is it test-specific? + ├─ YES → Use Local Mock + │ ├─ Error scenarios + │ ├─ Loading states + │ └─ Specific data + │ + └─ NO → Consider Global Mock + └─ If it becomes frequently used +``` + +# Common Anti-Patterns to Avoid + +❌ **Don't mock global dependencies locally:** +```js +// BAD - This is already globally mocked +jest.mock('react-router-dom', () => ({ ... })); +``` + +❌ **Don't create global mocks for test-specific data:** +```js +// BAD - This should be local +jest.mock('../api/tracesService', () => ({ + getTraces: jest.fn(() => specificTestData) +})); +``` + +✅ **Do use global mocks for infrastructure:** +```js +// GOOD - Use global mock +import { useLocation } from 'react-router-dom'; +``` + +✅ **Do create local mocks for business logic:** +```js +// GOOD - Local mock for specific test needs +jest.mock('../api/tracesService', () => ({ + getTraces: jest.fn(() => mockTracesData) +})); +``` \ No newline at end of file diff --git a/frontend/__mocks__/uplotMock.ts b/frontend/__mocks__/uplotMock.ts new file mode 100644 index 000000000000..9cf9add9f0c2 --- /dev/null +++ b/frontend/__mocks__/uplotMock.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Mock for uplot library used in tests +export interface MockUPlotInstance { + setData: jest.Mock; + setSize: jest.Mock; + destroy: jest.Mock; + redraw: jest.Mock; + setSeries: jest.Mock; +} + +export interface MockUPlotPaths { + spline: jest.Mock; + bars: jest.Mock; +} + +// Create mock instance methods +const createMockUPlotInstance = (): MockUPlotInstance => ({ + setData: jest.fn(), + setSize: jest.fn(), + destroy: jest.fn(), + redraw: jest.fn(), + setSeries: jest.fn(), +}); + +// Create mock paths +const mockPaths: MockUPlotPaths = { + spline: jest.fn(), + bars: jest.fn(), +}; + +// Mock static methods +const mockTzDate = jest.fn( + (date: Date, _timezone: string) => new Date(date.getTime()), +); + +// Mock uPlot constructor - this needs to be a proper constructor function +function MockUPlot( + _options: unknown, + _data: unknown, + _target: HTMLElement, +): MockUPlotInstance { + return createMockUPlotInstance(); +} + +// Add static methods to the constructor +MockUPlot.tzDate = mockTzDate; +MockUPlot.paths = mockPaths; + +// Export the constructor as default +export default MockUPlot; diff --git a/frontend/__mocks__/useSafeNavigate.ts b/frontend/__mocks__/useSafeNavigate.ts new file mode 100644 index 000000000000..a1044da052c7 --- /dev/null +++ b/frontend/__mocks__/useSafeNavigate.ts @@ -0,0 +1,29 @@ +// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests +interface SafeNavigateOptions { + replace?: boolean; + state?: unknown; +} + +interface SafeNavigateTo { + pathname?: string; + search?: string; + hash?: string; +} + +type SafeNavigateToType = string | SafeNavigateTo; + +interface UseSafeNavigateReturn { + safeNavigate: jest.MockedFunction< + (to: SafeNavigateToType, options?: SafeNavigateOptions) => void + >; +} + +export const useSafeNavigate = (): UseSafeNavigateReturn => ({ + safeNavigate: jest.fn( + (to: SafeNavigateToType, options?: SafeNavigateOptions) => { + console.log(`Mock safeNavigate called with:`, to, options); + }, + ) as jest.MockedFunction< + (to: SafeNavigateToType, options?: SafeNavigateOptions) => void + >, +}); diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 18b0989113ff..1d9255a329e8 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -1,5 +1,7 @@ import type { Config } from '@jest/types'; +const USE_SAFE_NAVIGATE_MOCK_PATH = '/__mocks__/useSafeNavigate.ts'; + const config: Config.InitialOptions = { clearMocks: true, coverageDirectory: 'coverage', @@ -10,6 +12,10 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', '\\.md$': '/__mocks__/cssMock.ts', + '^uplot$': '/__mocks__/uplotMock.ts', + '^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, }, globals: { extensionsToTreatAsEsm: ['.ts'], diff --git a/frontend/package.json b/frontend/package.json index b17d71d44edf..e98b64d688c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "@signozhq/design-tokens": "1.1.4", "@signozhq/input": "0.0.2", "@signozhq/popover": "0.0.0", + "@signozhq/resizable": "0.0.0", "@signozhq/sonner": "0.1.0", "@signozhq/table": "0.4.0", "@signozhq/tooltip": "0.0.2", @@ -138,6 +139,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "rehype-raw": "7.0.0", + "rrule": "2.8.1", "stream": "^0.0.2", "style-loader": "1.3.0", "styled-components": "^5.3.11", @@ -276,6 +278,7 @@ "serialize-javascript": "6.0.2", "prismjs": "1.30.0", "got": "11.8.5", - "form-data": "4.0.4" + "form-data": "4.0.4", + "brace-expansion": "^2.0.2" } } diff --git a/frontend/src/api/v1/download/downloadExportData.ts b/frontend/src/api/v1/download/downloadExportData.ts new file mode 100644 index 000000000000..30bc7b25dd78 --- /dev/null +++ b/frontend/src/api/v1/download/downloadExportData.ts @@ -0,0 +1,64 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp } from 'types/api'; +import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData'; + +export const downloadExportData = async ( + props: ExportRawDataProps, +): Promise => { + try { + const queryParams = new URLSearchParams(); + + queryParams.append('start', String(props.start)); + queryParams.append('end', String(props.end)); + queryParams.append('filter', props.filter); + props.columns.forEach((col) => { + queryParams.append('columns', col); + }); + queryParams.append('order_by', props.orderBy); + queryParams.append('limit', String(props.limit)); + queryParams.append('format', props.format); + + const response = await axios.get(`export_raw_data?${queryParams}`, { + responseType: 'blob', // Important: tell axios to handle response as blob + decompress: true, // Enable automatic decompression + headers: { + Accept: 'application/octet-stream', // Tell server we expect binary data + }, + timeout: 0, + }); + + // Only proceed if the response status is 200 + if (response.status !== 200) { + throw new Error( + `Failed to download data: server returned status ${response.status}`, + ); + } + // Create blob URL from response data + const blob = new Blob([response.data], { type: 'application/octet-stream' }); + const url = window.URL.createObjectURL(blob); + + // Create and configure download link + const link = document.createElement('a'); + link.href = url; + + // Get filename from Content-Disposition header or generate timestamped default + const filename = + response.headers['content-disposition'] + ?.split('filename=')[1] + ?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`; + + link.setAttribute('download', filename); + + // Trigger download + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default downloadExportData; diff --git a/frontend/src/api/v1/login/loginPrecheck.ts b/frontend/src/api/v1/login/loginPrecheck.ts index c0cdc3dcc43a..eac00182cb50 100644 --- a/frontend/src/api/v1/login/loginPrecheck.ts +++ b/frontend/src/api/v1/login/loginPrecheck.ts @@ -2,7 +2,7 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/user/loginPrecheck'; +import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck'; const loginPrecheck = async ( props: Props, diff --git a/frontend/src/api/v1/register/signup.ts b/frontend/src/api/v1/register/signup.ts index fcb483dffbaf..5838a8e7adf0 100644 --- a/frontend/src/api/v1/register/signup.ts +++ b/frontend/src/api/v1/register/signup.ts @@ -1,25 +1,21 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/user/loginPrecheck'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps, Signup } from 'types/api/user/loginPrecheck'; import { Props } from 'types/api/user/signup'; -const signup = async ( - props: Props, -): Promise | ErrorResponse> => { +const signup = async (props: Props): Promise> => { try { - const response = await axios.post(`/register`, { + const response = await axios.post(`/register`, { ...props, }); return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data?.data, + httpStatusCode: response.status, + data: response.data.data, }; } catch (error) { - return ErrorResponseHandler(error as AxiosError); + ErrorResponseHandlerV2(error as AxiosError); } }; diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss index 2f050ab78eb6..bfd29677ca02 100644 --- a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss @@ -2,10 +2,28 @@ position: relative; padding-left: 20px; + & :is(h1, h2, h3, h4, h5, h6, p, &-section-title) { + margin-bottom: 12px; + } + + &-content { + display: flex; + flex-direction: column; + gap: 32px; + } + + &-section-title { + font-size: 14px; + line-height: 20px; + color: var(--text-vanilla-400, #c0c1c3); + } + .changelog-release-date { font-size: 14px; line-height: 20px; color: var(--text-vanilla-400, #c0c1c3); + display: block; + margin-bottom: 12px; } &-list { @@ -81,12 +99,7 @@ } } - h1, - h2, - h3, - h4, - h5, - h6 { + & :is(h1, h2, h3, h4, h5, h6, p, &-section-title) { font-weight: 600; color: var(--text-vanilla-100, #fff); } @@ -96,7 +109,8 @@ line-height: 32px; } - h2 { + h2, + &-section-title { font-size: 20px; line-height: 28px; } @@ -108,6 +122,7 @@ overflow: hidden; border-radius: 4px; border: 1px solid var(--bg-slate-400, #1d212d); + margin-bottom: 28px; } .changelog-media-video { @@ -124,17 +139,8 @@ &-line { background-color: var(--bg-vanilla-300); } - li, - p { - color: var(--text-ink-500); - } - h1, - h2, - h3, - h4, - h5, - h6 { + & :is(h1, h2, h3, h4, h5, h6, p, li, &-section-title) { color: var(--text-ink-500); } diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx index fe2a7b953e4e..5113b4d6abc4 100644 --- a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx @@ -55,33 +55,35 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
{formattedReleaseDate} - {changelog.features && changelog.features.length > 0 && ( -
- {changelog.features.map((feature) => ( -
-

{feature.title}

- {feature.media && renderMedia(feature.media)} - {feature.description} -
- ))} -
- )} - {changelog.bug_fixes && changelog.bug_fixes.length > 0 && ( -
-

Bug Fixes

- {changelog.bug_fixes && ( - {changelog.bug_fixes} - )} -
- )} - {changelog.maintenance && changelog.maintenance.length > 0 && ( -
-

Maintenance

- {changelog.maintenance && ( - {changelog.maintenance} - )} -
- )} +
+ {changelog.features && changelog.features.length > 0 && ( +
+ {changelog.features.map((feature) => ( +
+
{feature.title}
+ {feature.media && renderMedia(feature.media)} + {feature.description} +
+ ))} +
+ )} + {changelog.bug_fixes && changelog.bug_fixes.length > 0 && ( +
+
Bug Fixes
+ {changelog.bug_fixes && ( + {changelog.bug_fixes} + )} +
+ )} + {changelog.maintenance && changelog.maintenance.length > 0 && ( +
+
Maintenance
+ {changelog.maintenance && ( + {changelog.maintenance} + )} +
+ )} +
); } diff --git a/frontend/src/components/CustomTimePicker/timezoneUtils.ts b/frontend/src/components/CustomTimePicker/timezoneUtils.ts index 92da405ba480..bca511e49707 100644 --- a/frontend/src/components/CustomTimePicker/timezoneUtils.ts +++ b/frontend/src/components/CustomTimePicker/timezoneUtils.ts @@ -119,7 +119,9 @@ const filterAndSortTimezones = ( return createTimezoneEntry(normalizedTz, offset); }); -const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => { +export const generateTimezoneData = ( + includeEtcTimezones = false, +): Timezone[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const allTimezones = (Intl as any).supportedValuesOf('timeZone'); const timezones: Timezone[] = []; diff --git a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx index bfe099a0ed59..f33db568a3b7 100644 --- a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx +++ b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx @@ -19,20 +19,6 @@ beforeAll(() => { }); }); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('react-dnd', () => ({ useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), diff --git a/frontend/src/components/ErrorModal/ErrorModal.test.tsx b/frontend/src/components/ErrorModal/ErrorModal.test.tsx index 64f880e8cece..cb39768c7d2c 100644 --- a/frontend/src/components/ErrorModal/ErrorModal.test.tsx +++ b/frontend/src/components/ErrorModal/ErrorModal.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; import APIError from 'types/api/error'; import ErrorModal from './ErrorModal'; @@ -56,9 +56,8 @@ describe('ErrorModal Component', () => { // Click the close button const closeButton = screen.getByTestId('close-button'); - act(() => { - fireEvent.click(closeButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(closeButton); // Check if onClose was called expect(onCloseMock).toHaveBeenCalledTimes(1); @@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => { // Click the trigger component const triggerButton = screen.getByText('Open Error Modal'); - act(() => { - fireEvent.click(triggerButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); // Check if the modal is displayed expect(screen.getByText('An error occurred')).toBeInTheDocument(); @@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => { // Click the trigger component const triggerButton = screen.getByText('error'); - act(() => { - fireEvent.click(triggerButton); - }); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); await waitFor(() => { expect(screen.getByText('An error occurred')).toBeInTheDocument(); }); // Trigger the onCancel event - act(() => { - fireEvent.click(screen.getByTestId('close-button')); - }); + await user.click(screen.getByTestId('close-button')); // Check if the modal is closed expect(onCloseMock).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/InputWithLabel/InputWithLabel.tsx b/frontend/src/components/InputWithLabel/InputWithLabel.tsx index 0e089acea986..a95318fe9cdf 100644 --- a/frontend/src/components/InputWithLabel/InputWithLabel.tsx +++ b/frontend/src/components/InputWithLabel/InputWithLabel.tsx @@ -49,6 +49,7 @@ function InputWithLabel({ value={inputValue} onChange={handleChange} name={label.toLowerCase()} + data-testid={`input-${label}`} /> {labelAfter && {label}} {onClose && ( diff --git a/frontend/src/components/Logs/ListLogView/index.tsx b/frontend/src/components/Logs/ListLogView/index.tsx index bdc8b2e77f0c..c77d31ddc81f 100644 --- a/frontend/src/components/Logs/ListLogView/index.tsx +++ b/frontend/src/components/Logs/ListLogView/index.tsx @@ -208,7 +208,11 @@ function ListLogView({ fontSize={fontSize} >
- +
{updatedSelecedFields.some((field) => field.name === 'body') && ( diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss index 5c2720e954c6..b3e51fe54f42 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss @@ -7,7 +7,6 @@ height: 100%; width: 3px; border-radius: 50px; - background-color: transparent; &.small { min-height: 16px; @@ -21,24 +20,107 @@ min-height: 24px; } - &.INFO { - background-color: var(--bg-robin-500); + // Severity variant CSS classes using design tokens + // Trace variants - + &.severity-trace-0 { + background-color: var(--bg-forest-600); } - &.WARNING, - &.WARN { - background-color: var(--bg-amber-500); + &.severity-trace-1 { + background-color: var(--bg-forest-500); } - &.ERROR { - background-color: var(--bg-cherry-500); - } - &.TRACE { + &.severity-trace-2 { background-color: var(--bg-forest-400); } - &.DEBUG { + &.severity-trace-3 { + background-color: var(--bg-forest-300); + } + &.severity-trace-4 { + background-color: var(--bg-forest-200); + } + + // Debug variants + &.severity-debug-0 { + background-color: var(--bg-aqua-600); + } + &.severity-debug-1 { background-color: var(--bg-aqua-500); } - &.FATAL { + &.severity-debug-2 { + background-color: var(--bg-aqua-400); + } + &.severity-debug-3 { + background-color: var(--bg-aqua-300); + } + &.severity-debug-4 { + background-color: var(--bg-aqua-200); + } + + // Info variants + &.severity-info-0 { + background-color: var(--bg-robin-600); + } + &.severity-info-1 { + background-color: var(--bg-robin-500); + } + &.severity-info-2 { + background-color: var(--bg-robin-400); + } + &.severity-info-3 { + background-color: var(--bg-robin-300); + } + &.severity-info-4 { + background-color: var(--bg-robin-200); + } + + // Warn variants + &.severity-warn-0 { + background-color: var(--bg-amber-600); + } + &.severity-warn-1 { + background-color: var(--bg-amber-500); + } + &.severity-warn-2 { + background-color: var(--bg-amber-400); + } + &.severity-warn-3 { + background-color: var(--bg-amber-300); + } + &.severity-warn-4 { + background-color: var(--bg-amber-200); + } + + // Error variants + &.severity-error-0 { + background-color: var(--bg-cherry-600); + } + &.severity-error-1 { + background-color: var(--bg-cherry-500); + } + &.severity-error-2 { + background-color: var(--bg-cherry-400); + } + &.severity-error-3 { + background-color: var(--bg-cherry-300); + } + &.severity-error-4 { + background-color: var(--bg-cherry-200); + } + + // Fatal variants + &.severity-fatal-0 { + background-color: var(--bg-sakura-600); + } + &.severity-fatal-1 { background-color: var(--bg-sakura-500); } + &.severity-fatal-2 { + background-color: var(--bg-sakura-400); + } + &.severity-fatal-3 { + background-color: var(--bg-sakura-300); + } + &.severity-fatal-4 { + background-color: var(--bg-sakura-200); + } } } diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx index 5ecddd5959c5..086710d74bbe 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx @@ -6,37 +6,41 @@ import LogStateIndicator from './LogStateIndicator'; describe('LogStateIndicator', () => { it('renders correctly with default props', () => { const { container } = render( - , + , ); const indicator = container.firstChild as HTMLElement; expect(indicator.classList.contains('log-state-indicator')).toBe(true); expect(indicator.classList.contains('isActive')).toBe(false); expect(container.querySelector('.line')).toBeTruthy(); - expect(container.querySelector('.line')?.classList.contains('INFO')).toBe( - true, - ); + expect( + container.querySelector('.line')?.classList.contains('severity-info-0'), + ).toBe(true); }); it('renders correctly with different types', () => { const { container: containerInfo } = render( - , - ); - expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe( - true, - ); - - const { container: containerWarning } = render( - , + , ); expect( - containerWarning.querySelector('.line')?.classList.contains('WARNING'), + containerInfo.querySelector('.line')?.classList.contains('severity-info-0'), + ).toBe(true); + + const { container: containerWarning } = render( + , + ); + expect( + containerWarning + .querySelector('.line') + ?.classList.contains('severity-warn-0'), ).toBe(true); const { container: containerError } = render( - , + , ); expect( - containerError.querySelector('.line')?.classList.contains('ERROR'), + containerError + .querySelector('.line') + ?.classList.contains('severity-error-0'), ).toBe(true); }); }); diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx index f831c6252a88..7f2eeb4ecaf9 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx @@ -3,6 +3,8 @@ import './LogStateIndicator.styles.scss'; import cx from 'classnames'; import { FontSize } from 'container/OptionsMenu/types'; +import { getLogTypeBySeverityNumber } from './utils'; + export const SEVERITY_TEXT_TYPE = { TRACE: 'TRACE', TRACE2: 'TRACE2', @@ -42,18 +44,112 @@ export const LogType = { UNKNOWN: 'UNKNOWN', } as const; +// Severity variant mapping to CSS classes +const SEVERITY_VARIANT_CLASSES: Record = { + // Trace variants - forest-600 to forest-200 + TRACE: 'severity-trace-0', + Trace: 'severity-trace-1', + trace: 'severity-trace-2', + trc: 'severity-trace-3', + Trc: 'severity-trace-4', + + // Debug variants - aqua-600 to aqua-200 + DEBUG: 'severity-debug-0', + Debug: 'severity-debug-1', + debug: 'severity-debug-2', + dbg: 'severity-debug-3', + Dbg: 'severity-debug-4', + + // Info variants - robin-600 to robin-200 + INFO: 'severity-info-0', + Info: 'severity-info-1', + info: 'severity-info-2', + Information: 'severity-info-3', + information: 'severity-info-4', + + // Warn variants - amber-600 to amber-200 + WARN: 'severity-warn-0', + WARNING: 'severity-warn-0', + Warn: 'severity-warn-1', + warn: 'severity-warn-2', + warning: 'severity-warn-3', + Warning: 'severity-warn-4', + wrn: 'severity-warn-3', + Wrn: 'severity-warn-4', + + // Error variants - cherry-600 to cherry-200 + // eslint-disable-next-line sonarjs/no-duplicate-string + ERROR: 'severity-error-0', + Error: 'severity-error-1', + error: 'severity-error-2', + err: 'severity-error-3', + Err: 'severity-error-4', + ERR: 'severity-error-0', + fail: 'severity-error-2', + Fail: 'severity-error-3', + FAIL: 'severity-error-0', + + // Fatal variants - sakura-600 to sakura-200 + // eslint-disable-next-line sonarjs/no-duplicate-string + FATAL: 'severity-fatal-0', + Fatal: 'severity-fatal-1', + fatal: 'severity-fatal-2', + // eslint-disable-next-line sonarjs/no-duplicate-string + critical: 'severity-fatal-3', + Critical: 'severity-fatal-4', + CRITICAL: 'severity-fatal-0', + crit: 'severity-fatal-3', + Crit: 'severity-fatal-4', + CRIT: 'severity-fatal-0', + panic: 'severity-fatal-2', + Panic: 'severity-fatal-3', + PANIC: 'severity-fatal-0', +}; + +function getSeverityClass( + severityText?: string, + severityNumber?: number, +): string { + // Priority 1: Use severityText for exact variant mapping + if (severityText) { + const variantClass = SEVERITY_VARIANT_CLASSES[severityText.trim()]; + if (variantClass) { + return variantClass; + } + } + + // Priority 2: Use severityNumber for base color (use middle shade as default) + if (severityNumber) { + const logType = getLogTypeBySeverityNumber(severityNumber); + if (logType !== LogType.UNKNOWN) { + return `severity-${logType.toLowerCase()}-0`; // Use middle shade (index 2) + } + } + + return 'severity-info-0'; // Fallback to CSS classes based on type +} + function LogStateIndicator({ - type, fontSize, + severityText, + severityNumber, }: { - type: string; fontSize: FontSize; + severityText?: string; + severityNumber?: number; }): JSX.Element { + const severityClass = getSeverityClass(severityText, severityNumber); + return (
-
+
); } +LogStateIndicator.defaultProps = { + severityText: '', + severityNumber: 0, +}; + export default LogStateIndicator; diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.ts b/frontend/src/components/Logs/LogStateIndicator/utils.ts index 03989a8dd602..963f319aceb0 100644 --- a/frontend/src/components/Logs/LogStateIndicator/utils.ts +++ b/frontend/src/components/Logs/LogStateIndicator/utils.ts @@ -41,7 +41,7 @@ const getLogTypeBySeverityText = (severityText: string): string => { }; // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber -const getLogTypeBySeverityNumber = (severityNumber: number): string => { +export const getLogTypeBySeverityNumber = (severityNumber: number): string => { if (severityNumber < 1) { return LogType.UNKNOWN; } diff --git a/frontend/src/components/Logs/RawLogView/RawLogView.styles.scss b/frontend/src/components/Logs/RawLogView/RawLogView.styles.scss deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index 4ad7329f8353..c9b73497c594 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -1,6 +1,5 @@ -import './RawLogView.styles.scss'; - -import { DrawerProps } from 'antd'; +import { Color } from '@signozhq/design-tokens'; +import { DrawerProps, Tooltip } from 'antd'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; @@ -26,7 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; import { getLogIndicatorType } from '../LogStateIndicator/utils'; // styles -import { RawLogContent, RawLogViewContainer } from './styles'; +import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles'; import { RawLogViewProps } from './types'; function RawLogView({ @@ -35,12 +34,17 @@ function RawLogView({ data, linesPerRow, isTextOverflowEllipsisDisabled, + isHighlighted, + helpTooltip, selectedFields = [], fontSize, + onLogClick, }: RawLogViewProps): JSX.Element { - const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink( - data.id, - ); + const { + isHighlighted: isUrlHighlighted, + isLogsExplorerPage, + onLogCopy, + } = useCopyLogLink(data.id); const flattenLogData = useMemo(() => FlatLogData(data), [data]); const { @@ -126,12 +130,20 @@ function RawLogView({ formatTimezoneAdjustedTimestamp, ]); - const handleClickExpand = useCallback(() => { - if (activeContextLog || isReadOnly) return; + const handleClickExpand = useCallback( + (event: MouseEvent) => { + if (activeContextLog || isReadOnly) return; - onSetActiveLog(data); - setSelectedTab(VIEW_TYPES.OVERVIEW); - }, [activeContextLog, isReadOnly, data, onSetActiveLog]); + // Use custom click handler if provided, otherwise use default behavior + if (onLogClick) { + onLogClick(data, event); + } else { + onSetActiveLog(data); + setSelectedTab(VIEW_TYPES.OVERVIEW); + } + }, + [activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick], + ); const handleCloseLogDetail: DrawerProps['onClose'] = useCallback( ( @@ -183,16 +195,30 @@ function RawLogView({ align="middle" $isDarkMode={isDarkMode} $isReadOnly={isReadOnly} - $isHightlightedLog={isHighlighted} + $isHightlightedLog={isUrlHighlighted} $isActiveLog={ activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog } + $isCustomHighlighted={isHighlighted} $logType={logType} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} fontSize={fontSize} > - + + {helpTooltip && ( + + + + )} ` @@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{ }; transition: background-color 2s ease-in;` : ''} + + ${({ $isCustomHighlighted, $isDarkMode, $logType }): string => + getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)} +`; + +export const InfoIconWrapper = styled(Info)` + display: flex; + align-items: center; + margin-right: 4px; + cursor: help; + flex-shrink: 0; + height: auto; `; export const ExpandIconWrapper = styled(Col)` diff --git a/frontend/src/components/Logs/RawLogView/types.ts b/frontend/src/components/Logs/RawLogView/types.ts index ed73725dcc34..5cbc3e8c2635 100644 --- a/frontend/src/components/Logs/RawLogView/types.ts +++ b/frontend/src/components/Logs/RawLogView/types.ts @@ -1,4 +1,5 @@ import { FontSize } from 'container/OptionsMenu/types'; +import { MouseEvent } from 'react'; import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; @@ -6,10 +7,13 @@ export interface RawLogViewProps { isActiveLog?: boolean; isReadOnly?: boolean; isTextOverflowEllipsisDisabled?: boolean; + isHighlighted?: boolean; + helpTooltip?: string; data: ILog; linesPerRow: number; fontSize: FontSize; selectedFields?: IField[]; + onLogClick?: (log: ILog, event: MouseEvent) => void; } export interface RawLogContentProps { diff --git a/frontend/src/components/Logs/TableView/useTableView.tsx b/frontend/src/components/Logs/TableView/useTableView.tsx index 95b03e1e9ca5..a355c6372af4 100644 --- a/frontend/src/components/Logs/TableView/useTableView.tsx +++ b/frontend/src/components/Logs/TableView/useTableView.tsx @@ -11,7 +11,6 @@ import { useTimezone } from 'providers/Timezone'; import { useMemo } from 'react'; import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; -import { getLogIndicatorTypeForTable } from '../LogStateIndicator/utils'; import { defaultListViewPanelStyle, defaultTableStyle, @@ -93,8 +92,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { children: (
), diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss new file mode 100644 index 000000000000..e9f2d92df378 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss @@ -0,0 +1,86 @@ +.logs-download-popover { + .ant-popover-inner { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + var(--bg-ink-400) 0%, + var(--bg-ink-500) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + padding: 0 8px 12px 8px; + margin: 6px 0; + } + + .export-options-container { + width: 240px; + border-radius: 4px; + + .title { + display: flex; + color: var(--bg-slate-50); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 18px; + letter-spacing: 0.88px; + text-transform: uppercase; + margin-bottom: 8px; + } + + .export-format, + .row-limit, + .columns-scope { + padding: 12px 4px; + display: flex; + flex-direction: column; + + :global(.ant-radio-wrapper) { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 13px; + } + } + + .horizontal-line { + height: 1px; + background: var(--bg-slate-400); + } + + .export-button { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + } + } +} + +.lightMode { + .logs-download-popover { + .ant-popover-inner { + border: 1px solid var(--bg-vanilla-300); + background: linear-gradient( + 139deg, + var(--bg-vanilla-100) 0%, + var(--bg-vanilla-300) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2); + } + .export-options-container { + .title { + color: var(--bg-ink-200); + } + + :global(.ant-radio-wrapper) { + color: var(--bg-ink-400); + } + + .horizontal-line { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx new file mode 100644 index 000000000000..eaf7456039c2 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx @@ -0,0 +1,341 @@ +import '@testing-library/jest-dom'; + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { message } from 'antd'; +import { ENVIRONMENT } from 'constants/env'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; + +import { DownloadFormats, DownloadRowCounts } from './constants'; +import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu'; + +// Mock antd message +jest.mock('antd', () => { + const actual = jest.requireActual('antd'); + return { + ...actual, + message: { + success: jest.fn(), + error: jest.fn(), + }, + }; +}); + +const TEST_IDS = { + DOWNLOAD_BUTTON: 'periscope-btn-download-options', +} as const; + +interface TestProps { + startTime: number; + endTime: number; + filter: string; + columns: TelemetryFieldKey[]; + orderBy: string; +} + +const createTestProps = (): TestProps => ({ + startTime: 1631234567890, + endTime: 1631234567999, + filter: 'status = 200', + columns: [ + { + name: 'http.status', + fieldContext: 'attribute', + fieldDataType: 'int64', + } as TelemetryFieldKey, + ], + orderBy: 'timestamp:desc', +}); + +const testRenderContent = (props: TestProps): void => { + render( + , + ); +}; + +const testSuccessResponse = (res: any, ctx: any): any => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/octet-stream'), + ctx.set('Content-Disposition', 'attachment; filename="export.csv"'), + ctx.body('id,value\n1,2\n'), + ); + +describe('LogsDownloadOptionsMenu', () => { + const BASE_URL = ENVIRONMENT.baseURL; + const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`; + let requestSpy: jest.Mock; + const setupDefaultServer = (): void => { + server.use( + rest.get(EXPORT_URL, (req, res, ctx) => { + const params = req.url.searchParams; + const payload = { + start: Number(params.get('start')), + end: Number(params.get('end')), + filter: params.get('filter'), + columns: params.getAll('columns'), + order_by: params.get('order_by'), + limit: Number(params.get('limit')), + format: params.get('format'), + }; + requestSpy(payload); + return testSuccessResponse(res, ctx); + }), + ); + }; + + // Mock URL.createObjectURL used by download logic + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + beforeEach(() => { + requestSpy = jest.fn(); + setupDefaultServer(); + (message.success as jest.Mock).mockReset(); + (message.error as jest.Mock).mockReset(); + // jsdom doesn't implement it by default + ((URL as unknown) as { + createObjectURL: (b: Blob) => string; + }).createObjectURL = jest.fn(() => 'blob:mock'); + ((URL as unknown) as { + revokeObjectURL: (u: string) => void; + }).revokeObjectURL = jest.fn(); + }); + + beforeAll(() => { + server.listen(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + // restore + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + it('renders download button', () => { + const props = createTestProps(); + testRenderContent(props); + + const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('periscope-btn', 'ghost'); + }); + + it('shows popover with export options when download button is clicked', () => { + const props = createTestProps(); + render( + , + ); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('FORMAT')).toBeInTheDocument(); + expect(screen.getByText('Number of Rows')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + }); + + it('allows changing export format', () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const csvRadio = screen.getByRole('radio', { name: 'csv' }); + const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' }); + + expect(csvRadio).toBeChecked(); + fireEvent.click(jsonlRadio); + expect(jsonlRadio).toBeChecked(); + expect(csvRadio).not.toBeChecked(); + }); + + it('allows changing row limit', () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const tenKRadio = screen.getByRole('radio', { name: '10k' }); + const fiftyKRadio = screen.getByRole('radio', { name: '50k' }); + + expect(tenKRadio).toBeChecked(); + fireEvent.click(fiftyKRadio); + expect(fiftyKRadio).toBeChecked(); + expect(tenKRadio).not.toBeChecked(); + }); + + it('allows changing columns scope', () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const allColumnsRadio = screen.getByRole('radio', { name: 'All' }); + const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' }); + + expect(allColumnsRadio).toBeChecked(); + fireEvent.click(selectedColumnsRadio); + expect(selectedColumnsRadio).toBeChecked(); + expect(allColumnsRadio).not.toBeChecked(); + }); + + it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByRole('radio', { name: 'Selected' })); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + start: props.startTime, + end: props.endTime, + columns: ['attribute.http.status:int64'], + filter: props.filter, + order_by: props.orderBy, + format: DownloadFormats.CSV, + limit: DownloadRowCounts.TEN_K, + }), + ); + }); + }); + + it('calls downloadExportData with correct parameters when export button is clicked', async () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByRole('radio', { name: 'All' })); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + start: props.startTime, + end: props.endTime, + columns: [], + filter: props.filter, + order_by: props.orderBy, + format: DownloadFormats.CSV, + limit: DownloadRowCounts.TEN_K, + }), + ); + }); + }); + + it('handles successful export with success message', async () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(message.success).toHaveBeenCalledWith( + 'Export completed successfully', + ); + }); + }); + + it('handles export failure with error message', async () => { + // Override handler to return 500 for this test + server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500)))); + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(message.error).toHaveBeenCalledWith( + 'Failed to export logs. Please try again.', + ); + }); + }); + + it('handles UI state correctly during export process', async () => { + server.use( + rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)), + ); + const props = createTestProps(); + testRenderContent(props); + + // Open popover + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Start export + fireEvent.click(screen.getByText('Export')); + + // Check button is disabled during export + expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled(); + + // Check popover is closed immediately after export starts + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Wait for export to complete and verify button is enabled again + await waitFor(() => { + expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled(); + }); + }); + + it('uses filename from Content-Disposition and triggers download click', async () => { + server.use( + rest.get(EXPORT_URL, (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/octet-stream'), + ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'), + ctx.body('row\n'), + ), + ), + ); + + const originalCreateElement = document.createElement.bind(document); + const anchorEl = originalCreateElement('a') as HTMLAnchorElement; + const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute'); + const clickSpy = jest.spyOn(anchorEl, 'click'); + const removeSpy = jest.spyOn(anchorEl, 'remove'); + const createElSpy = jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: any): any => + tagName === 'a' ? anchorEl : originalCreateElement(tagName), + ); + const appendSpy = jest.spyOn(document.body, 'appendChild'); + + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(appendSpy).toHaveBeenCalledWith(anchorEl); + expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl'); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + }); + expect(anchorEl.getAttribute('download')).toBe('report.jsonl'); + + createElSpy.mockRestore(); + appendSpy.mockRestore(); + }); +}); diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx new file mode 100644 index 000000000000..655da183cd4d --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx @@ -0,0 +1,170 @@ +import './LogsDownloadOptionsMenu.styles.scss'; + +import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd'; +import { downloadExportData } from 'api/v1/download/downloadExportData'; +import { Download, DownloadIcon, Loader2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; + +import { + DownloadColumnsScopes, + DownloadFormats, + DownloadRowCounts, +} from './constants'; + +function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string { + const prefix = key.fieldContext ? `${key.fieldContext}.` : ''; + const suffix = key.fieldDataType ? `:${key.fieldDataType}` : ''; + return `${prefix}${key.name}${suffix}`; +} + +interface LogsDownloadOptionsMenuProps { + startTime: number; + endTime: number; + filter: string; + columns: TelemetryFieldKey[]; + orderBy: string; +} + +export default function LogsDownloadOptionsMenu({ + startTime, + endTime, + filter, + columns, + orderBy, +}: LogsDownloadOptionsMenuProps): JSX.Element { + const [exportFormat, setExportFormat] = useState(DownloadFormats.CSV); + const [rowLimit, setRowLimit] = useState(DownloadRowCounts.TEN_K); + const [columnsScope, setColumnsScope] = useState( + DownloadColumnsScopes.ALL, + ); + const [isDownloading, setIsDownloading] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleExportRawData = useCallback(async (): Promise => { + setIsPopoverOpen(false); + try { + setIsDownloading(true); + const downloadOptions = { + source: 'logs', + start: startTime, + end: endTime, + columns: + columnsScope === DownloadColumnsScopes.SELECTED + ? columns.map((col) => convertTelemetryFieldKeyToText(col)) + : [], + filter, + orderBy, + format: exportFormat, + limit: rowLimit, + }; + + await downloadExportData(downloadOptions); + message.success('Export completed successfully'); + } catch (error) { + console.error('Error exporting logs:', error); + message.error('Failed to export logs. Please try again.'); + } finally { + setIsDownloading(false); + } + }, [ + startTime, + endTime, + columnsScope, + columns, + filter, + orderBy, + exportFormat, + rowLimit, + setIsDownloading, + setIsPopoverOpen, + ]); + + const popoverContent = useMemo( + () => ( +
+
+ FORMAT + setExportFormat(e.target.value)} + > + csv + jsonl + +
+ +
+ +
+ Number of Rows + setRowLimit(e.target.value)} + > + 10k + 30k + 50k + +
+ +
+ +
+ Columns + setColumnsScope(e.target.value)} + > + All + Selected + +
+ + +
+ ), + [exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData], + ); + + return ( + + + + ), +})); + +jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({ + OrderByFilter: ({ onChange }: any) => ( + + ), +})); + +jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({ + __esModule: true, + default: ({ onChange, onClose }: any) => ( +
+ + +
+ ), +})); + +jest.mock( + 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter', + () => ({ + ReduceToFilter: ({ onChange }: any) => ( + + ), + }), +); + +function baseQuery(overrides: Partial = {}): any { + return { + dataSource: DataSource.TRACES, + aggregations: [{ id: 'a', operator: 'count' }], + groupBy: [], + orderBy: [], + legend: '', + limit: null, + having: { expression: '' }, + ...overrides, + }; +} + +describe('QueryAddOns', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('VALUE panel: no sections auto-open when query has no active add-ons', () => { + render( + , + ); + + expect(screen.queryByTestId('legend-format-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reduce-to-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('order-by-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('limit-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('having-content')).not.toBeInTheDocument(); + }); + + it('hides group-by section for METRICS even if groupBy is set in query', () => { + render( + , + ); + + expect(screen.queryByTestId('group-by-content')).not.toBeInTheDocument(); + }); + + it('defaults to Order By open in list view panel', () => { + render( + , + ); + + expect(screen.getByTestId('order-by-content')).toBeInTheDocument(); + }); + + it('limit input auto-opens when limit is set and changing it calls handler', () => { + render( + , + ); + + const input = screen.getByTestId('input-Limit') as HTMLInputElement; + expect(screen.getByTestId('limit-content')).toBeInTheDocument(); + expect(input.value).toBe('5'); + + fireEvent.change(input, { target: { value: '10' } }); + expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10); + }); + + it('auto-opens Order By and Limit when present in query', () => { + const query = baseQuery({ + orderBy: [{ columnName: 'duration', order: 'desc' }], + limit: 7, + }); + render( + , + ); + + expect(screen.getByTestId('order-by-content')).toBeInTheDocument(); + const limitInput = screen.getByTestId('input-Limit') as HTMLInputElement; + expect(screen.getByTestId('limit-content')).toBeInTheDocument(); + expect(limitInput.value).toBe('7'); + }); +}); diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx index c70ca59c2df6..efa1ee536e81 100644 --- a/frontend/src/components/QuickFilters/QuickFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { filterConfig, isDynamicFilters, customFilters, - setIsStale, + refetchCustomFilters, isCustomFiltersLoading, } = useFilterConfig({ signal, config }); @@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { signal={signal} setIsSettingsOpen={setIsSettingsOpen} customFilters={customFilters} - setIsStale={setIsStale} + refetchCustomFilters={refetchCustomFilters} /> )}
diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx index aa78d2610781..20e8e5b581a4 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.tsx @@ -14,12 +14,12 @@ function QuickFiltersSettings({ signal, setIsSettingsOpen, customFilters, - setIsStale, + refetchCustomFilters, }: { signal: SignalType | undefined; setIsSettingsOpen: (isSettingsOpen: boolean) => void; customFilters: FilterType[]; - setIsStale: (isStale: boolean) => void; + refetchCustomFilters: () => void; }): JSX.Element { const { handleSettingsClose, @@ -34,7 +34,7 @@ function QuickFiltersSettings({ } = useQuickFilterSettings({ setIsSettingsOpen, customFilters, - setIsStale, + refetchCustomFilters, signal, }); diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx index bf4406c3045a..42be1bece827 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx @@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters'; interface UseQuickFilterSettingsProps { setIsSettingsOpen: (isSettingsOpen: boolean) => void; customFilters: FilterType[]; - setIsStale: (isStale: boolean) => void; + refetchCustomFilters: () => void; signal?: SignalType; } @@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn { const useQuickFilterSettings = ({ customFilters, setIsSettingsOpen, - setIsStale, + refetchCustomFilters, signal, }: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => { const [inputValue, setInputValue] = useState(''); @@ -46,7 +46,7 @@ const useQuickFilterSettings = ({ } = useMutation(updateCustomFiltersAPI, { onSuccess: () => { setIsSettingsOpen(false); - setIsStale(true); + refetchCustomFilters(); logEvent('Quick Filters Settings: changes saved', { addedFilters, }); diff --git a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx index fb2659a6817b..2f7d0bc70ed1 100644 --- a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx +++ b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx @@ -1,12 +1,8 @@ import getCustomFilters from 'api/quickFilters/getCustomFilters'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useQuery } from 'react-query'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - Filter as FilterType, - PayloadProps, -} from 'types/api/quickFilters/getCustomFilters'; +import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters'; import { IQuickFiltersConfig, SignalType } from '../types'; import { getFilterConfig } from '../utils'; @@ -18,37 +14,34 @@ interface UseFilterConfigProps { interface UseFilterConfigReturn { filterConfig: IQuickFiltersConfig[]; customFilters: FilterType[]; - setCustomFilters: React.Dispatch>; isCustomFiltersLoading: boolean; isDynamicFilters: boolean; - setIsStale: React.Dispatch>; + refetchCustomFilters: () => void; } const useFilterConfig = ({ signal, config, }: UseFilterConfigProps): UseFilterConfigReturn => { - const [customFilters, setCustomFilters] = useState([]); - const [isStale, setIsStale] = useState(true); + const { + isFetching: isCustomFiltersLoading, + data: customFilters = [], + refetch, + } = useQuery( + [REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal], + async () => { + const res = await getCustomFilters({ signal: signal || '' }); + return 'payload' in res && res.payload?.filters ? res.payload.filters : []; + }, + { + enabled: !!signal, + }, + ); + const isDynamicFilters = useMemo(() => customFilters.length > 0, [ customFilters, ]); - const { isFetching: isCustomFiltersLoading } = useQuery< - SuccessResponse | ErrorResponse, - Error - >( - [REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal], - () => getCustomFilters({ signal: signal || '' }), - { - onSuccess: (data) => { - if ('payload' in data && data.payload?.filters) { - setCustomFilters(data.payload.filters || ([] as FilterType[])); - } - setIsStale(false); - }, - enabled: !!signal && isStale, - }, - ); + const filterConfig = useMemo( () => getFilterConfig(signal, customFilters, config), [config, customFilters, signal], @@ -57,10 +50,9 @@ const useFilterConfig = ({ return { filterConfig, customFilters, - setCustomFilters, isCustomFiltersLoading, isDynamicFilters, - setIsStale, + refetchCustomFilters: refetch, }; }; diff --git a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx index 252a4d23084a..bf01c8e8923d 100644 --- a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx +++ b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx @@ -1,15 +1,6 @@ import '@testing-library/jest-dom'; -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; import { ENVIRONMENT } from 'constants/env'; -import ROUTES from 'constants/routes'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { otherFiltersResponse, @@ -18,8 +9,7 @@ import { } from 'mocks-server/__mockdata__/customQuickFilters'; import { server } from 'mocks-server/server'; import { rest } from 'msw'; -import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; -import { USER_ROLES } from 'types/roles'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; import QuickFilters from '../QuickFilters'; import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types'; @@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ useQueryBuilder: jest.fn(), })); -// eslint-disable-next-line sonarjs/no-duplicate-string -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: (): { pathname: string } => ({ - pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`, - }), -})); - -const userRole = USER_ROLES.ADMIN; - -// mock useAppContext -jest.mock('providers/App/App', () => ({ - useAppContext: jest.fn(() => ({ user: { role: userRole } })), -})); - const handleFilterVisibilityChange = jest.fn(); const redirectWithQueryBuilderData = jest.fn(); const putHandler = jest.fn(); @@ -78,11 +53,10 @@ const setupServer = (): void => { putHandler(await req.json()); return res(ctx.status(200), ctx.json({})); }), - - rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) => + rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) => res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), ), - rest.get(fieldsValuesURL, (req, res, ctx) => + rest.get(fieldsValuesURL, (_req, res, ctx) => res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)), ), ); @@ -96,14 +70,12 @@ function TestQuickFilters({ config?: IQuickFiltersConfig[]; }): JSX.Element { return ( - - - + ); } @@ -118,11 +90,11 @@ beforeAll(() => { afterEach(() => { server.resetHandlers(); + jest.clearAllMocks(); }); afterAll(() => { server.close(); - cleanup(); }); beforeEach(() => { @@ -151,9 +123,13 @@ describe('Quick Filters', () => { }); it('should add filter data to query when checkbox is clicked', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); - const checkbox = screen.getByText('mq-kafka'); - fireEvent.click(checkbox); + + // Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK + const target = await screen.findByText('mq-kafka'); + await user.click(target); await waitFor(() => { expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( @@ -182,16 +158,20 @@ describe('Quick Filters', () => { describe('Quick Filters with custom filters', () => { it('loads the custom filters correctly', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); + expect(screen.getByText('Filters for')).toBeInTheDocument(); expect(screen.getByText(QUERY_NAME)).toBeInTheDocument(); + await screen.findByText(FILTER_SERVICE_NAME); const allByText = await screen.findAllByText('otel-demo'); - // since 2 filter collapse are open, there are 2 filter items visible expect(allByText).toHaveLength(2); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); expect(await screen.findByText('Edit quick filters')).toBeInTheDocument(); @@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => { }); it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME); const addButton = otherFilterItem.parentElement?.querySelector('button'); expect(addButton).not.toBeNull(); - fireEvent.click(addButton as HTMLButtonElement); + await user.click(addButton as HTMLButtonElement); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; await waitFor(() => { @@ -225,17 +208,21 @@ describe('Quick Filters with custom filters', () => { }); it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + + await user.click(removeBtn as HTMLButtonElement); await waitFor(() => { expect(addedSection).not.toContainElement( @@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => { }); it('restores original filter state on Discard', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!; const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + await user.click(removeBtn as HTMLButtonElement); const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!; await waitFor(() => { @@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => { ); }); - fireEvent.click(screen.getByText(DISCARD_TEXT)); + const discardBtn = screen + .getByText(DISCARD_TEXT) + .closest('button') as HTMLButtonElement; + expect(discardBtn).not.toBeNull(); + await user.click(discardBtn); await waitFor(() => { expect(addedSection).toContainElement( @@ -285,18 +279,25 @@ describe('Quick Filters with custom filters', () => { }); it('saves the updated filters by calling PUT with correct payload', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); await screen.findByText(FILTER_SERVICE_NAME); const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); - fireEvent.click(icon); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); const target = await screen.findByText(FILTER_OS_DESCRIPTION); const removeBtn = target.parentElement?.querySelector('button'); expect(removeBtn).not.toBeNull(); - fireEvent.click(removeBtn as HTMLButtonElement); + await user.click(removeBtn as HTMLButtonElement); - fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT)); + const saveBtn = screen + .getByText(SAVE_CHANGES_TEXT) + .closest('button') as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + await user.click(saveBtn); await waitFor(() => { expect(putHandler).toHaveBeenCalled(); @@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => { expect(requestBody.signal).toBe(SIGNAL); }); - // render duration filter it('should render duration slider for duration_nono filter', async () => { - // Set up fake timers **before rendering** + // Use fake timers only in this test (for debounce), and wire them to userEvent jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: (ms) => jest.advanceTimersByTime(ms), + pointerEventsCheck: 0, + }); const { getByTestId } = render(); await screen.findByText(FILTER_SERVICE_NAME); expect(screen.getByText('Duration')).toBeInTheDocument(); - // click to open the duration filter - fireEvent.click(screen.getByText('Duration')); + // Open the duration section (use role if it’s a button/collapse) + await user.click(screen.getByText('Duration')); const minDuration = getByTestId('min-input') as HTMLInputElement; const maxDuration = getByTestId('max-input') as HTMLInputElement; + expect(minDuration).toHaveValue(null); expect(minDuration).toHaveProperty('placeholder', '0'); expect(maxDuration).toHaveValue(null); expect(maxDuration).toHaveProperty('placeholder', '100000000'); - await act(async () => { - // set values - fireEvent.change(minDuration, { target: { value: '10000' } }); - fireEvent.change(maxDuration, { target: { value: '20000' } }); - jest.advanceTimersByTime(2000); - }); + // Type values and advance debounce + await user.clear(minDuration); + await user.type(minDuration, '10000'); + await user.clear(maxDuration); + await user.type(maxDuration, '20000'); + jest.advanceTimersByTime(2000); + await waitFor(() => { expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( expect.objectContaining({ @@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => { ); }); - jest.useRealTimers(); // Clean up + jest.useRealTimers(); + }); +}); + +describe('Quick Filters refetch behavior', () => { + it('fetches custom filters on every mount when signal is provided', async () => { + let getCalls = 0; + + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => { + getCalls += 1; + return res(ctx.status(200), ctx.json(quickFiltersListResponse)); + }), + ); + + const { unmount } = render(); + expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument(); + + unmount(); + + render(); + expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument(); + + expect(getCalls).toBe(2); + }); + + it('does not fetch custom filters when signal is undefined', async () => { + let getCalls = 0; + + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => { + getCalls += 1; + return res(ctx.status(200), ctx.json(quickFiltersListResponse)); + }), + ); + + render(); + + await waitFor(() => expect(getCalls).toBe(0)); + }); + + it('refetches custom filters after saving settings', async () => { + let getCalls = 0; + putHandler.mockClear(); + + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => { + getCalls += 1; + return res(ctx.status(200), ctx.json(quickFiltersListResponse)); + }), + rest.put(saveQuickFiltersURL, async (req, res, ctx) => { + putHandler(await req.json()); + return res(ctx.status(200), ctx.json({})); + }), + ); + + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); + + expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument(); + + const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); + + const target = await screen.findByText(FILTER_OS_DESCRIPTION); + const removeBtn = target.parentElement?.querySelector( + 'button', + ) as HTMLButtonElement; + await user.click(removeBtn); + + await user.click(screen.getByText(SAVE_CHANGES_TEXT)); + + await waitFor(() => expect(putHandler).toHaveBeenCalled()); + await waitFor(() => expect(getCalls).toBeGreaterThanOrEqual(2)); + }); + + it('renders updated filters after refetch post-save', async () => { + const updatedResponse = { + ...quickFiltersListResponse, + data: { + ...quickFiltersListResponse.data, + filters: [ + ...(quickFiltersListResponse.data.filters ?? []), + { + key: 'new.custom.filter', + dataType: 'string', + type: 'resource', + } as const, + ], + }, + }; + + let getCount = 0; + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => { + getCount += 1; + return getCount >= 2 + ? res(ctx.status(200), ctx.json(updatedResponse)) + : res(ctx.status(200), ctx.json(quickFiltersListResponse)); + }), + rest.put(saveQuickFiltersURL, async (_req, res, ctx) => + res(ctx.status(200), ctx.json({})), + ), + ); + + const user = userEvent.setup({ pointerEventsCheck: 0 }); + render(); + + expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument(); + + const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID); + const settingsButton = icon.closest('button') ?? icon; + await user.click(settingsButton); + + // Make a minimal change so Save button appears + const target = await screen.findByText(FILTER_OS_DESCRIPTION); + const removeBtn = target.parentElement?.querySelector( + 'button', + ) as HTMLButtonElement; + await user.click(removeBtn); + + await user.click(screen.getByText(SAVE_CHANGES_TEXT)); + + await waitFor(() => { + expect(screen.getByText('New Custom Filter')).toBeInTheDocument(); + }); + }); + + it('shows empty state when GET fails', async () => { + server.use( + rest.get(quickFiltersListURL, (_req, res, ctx) => + res(ctx.status(500), ctx.json({})), + ), + ); + + render(); + + expect(await screen.findByText('No filters found')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx b/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx index 9c9f0b88aa2b..e99faf04457c 100644 --- a/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx +++ b/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx @@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio'; interface Option { value: string; - label: string; + label: string | React.ReactNode; icon?: React.ReactNode; } diff --git a/frontend/src/components/SpanHoverCard/SpanHoverCard.styles.scss b/frontend/src/components/SpanHoverCard/SpanHoverCard.styles.scss new file mode 100644 index 000000000000..7c243c8508b6 --- /dev/null +++ b/frontend/src/components/SpanHoverCard/SpanHoverCard.styles.scss @@ -0,0 +1,108 @@ +.span-hover-card { + width: 206px; + + .ant-popover-inner { + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.32) 0%, + rgba(18, 19, 23, 0.36) 98.68% + ); + padding: 12px 16px; + border: 1px solid var(--bg-slate-500); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.32) 0%, + rgba(18, 19, 23, 0.36) 98.68% + ); + backdrop-filter: blur(20px); + border-radius: 4px; + z-index: -1; + will-change: background-color, backdrop-filter; + } + } + + &__title { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.5rem; + } + + &__operation { + color: var(--bg-vanilla-100); + font-size: 12px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.48px; + } + + &__service { + font-size: 0.875rem; + color: var(--bg-vanilla-400); + font-weight: 400; + } + + &__error { + font-size: 0.75rem; + color: var(--bg-cherry-500); + font-weight: 500; + } + + &__row { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 174px; + margin-top: 8px; + } + + &__label { + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 500; + line-height: 20px; + } + + &__value { + color: var(--bg-vanilla-100); + font-size: 12px; + font-weight: 500; + line-height: 20px; + text-align: right; + } + + &__relative-time { + display: flex; + align-items: center; + margin-top: 4px; + gap: 8px; + border-radius: 1px 0 0 1px; + background: linear-gradient( + 90deg, + hsla(358, 75%, 59%, 0.2) 0%, + rgba(229, 72, 77, 0) 100% + ); + + &-icon { + width: 2px; + height: 20px; + flex-shrink: 0; + border-radius: 2px; + background: var(--bg-cherry-500); + } + } + + &__relative-text { + color: var(--bg-cherry-300); + font-size: 12px; + line-height: 20px; + } +} diff --git a/frontend/src/components/SpanHoverCard/SpanHoverCard.tsx b/frontend/src/components/SpanHoverCard/SpanHoverCard.tsx new file mode 100644 index 000000000000..2ae0d7647e3c --- /dev/null +++ b/frontend/src/components/SpanHoverCard/SpanHoverCard.tsx @@ -0,0 +1,101 @@ +import './SpanHoverCard.styles.scss'; + +import { Popover, Typography } from 'antd'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils'; +import dayjs from 'dayjs'; +import { ReactNode } from 'react'; +import { Span } from 'types/api/trace/getTraceV2'; +import { toFixed } from 'utils/toFixed'; + +interface ITraceMetadata { + startTime: number; + endTime: number; +} + +interface SpanHoverCardProps { + span: Span; + traceMetadata: ITraceMetadata; + children: ReactNode; +} + +function SpanHoverCard({ + span, + traceMetadata, + children, +}: SpanHoverCardProps): JSX.Element { + const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds + const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit( + duration, + ); + + // Calculate relative start time from trace start + const relativeStartTime = span.timestamp - traceMetadata.startTime; + const { + time: relativeTime, + timeUnitName: relativeTimeUnit, + } = convertTimeToRelevantUnit(relativeStartTime); + + // Format absolute start time + const startTimeFormatted = dayjs(span.timestamp).format( + DATE_TIME_FORMATS.SPAN_POPOVER_DATE, + ); + + const getContent = (): JSX.Element => ( +
+
+ + Duration: + + + {toFixed(formattedDuration, 2)} + {timeUnitName} + +
+
+ + Events: + + + {span.event?.length || 0} + +
+
+ + Start time: + + + {startTimeFormatted} + +
+
+
+ + {toFixed(relativeTime, 2)} + {relativeTimeUnit} after trace start + +
+
+ ); + + return ( + + + {span.name} + +
+ } + content={getContent()} + trigger="hover" + rootClassName="span-hover-card" + autoAdjustOverflow + arrow={false} + > + {children} + + ); +} + +export default SpanHoverCard; diff --git a/frontend/src/components/Uplot/Uplot.tsx b/frontend/src/components/Uplot/Uplot.tsx index cd9ece521271..8bb044552a3d 100644 --- a/frontend/src/components/Uplot/Uplot.tsx +++ b/frontend/src/components/Uplot/Uplot.tsx @@ -62,7 +62,7 @@ const Uplot = forwardRef( useEffect(() => { onCreateRef.current = onCreate; onDeleteRef.current = onDelete; - }); + }, [onCreate, onDelete]); const destroy = useCallback((chart: uPlot | null) => { if (chart) { @@ -71,12 +71,25 @@ const Uplot = forwardRef( chartRef.current = null; } - // remove chart tooltip on cleanup + // Clean up tooltip overlay that might be detached const overlay = document.getElementById('overlay'); - if (overlay) { + // Remove all child elements from overlay + while (overlay.firstChild) { + overlay.removeChild(overlay.firstChild); + } overlay.style.display = 'none'; } + + // Clean up any remaining tooltips that might be detached + const tooltips = document.querySelectorAll( + '.uplot-tooltip, .tooltip-container', + ); + tooltips.forEach((tooltip) => { + if (tooltip && tooltip.parentNode) { + tooltip.parentNode.removeChild(tooltip); + } + }); }, []); const create = useCallback(() => { diff --git a/frontend/src/constants/antlrQueryConstants.ts b/frontend/src/constants/antlrQueryConstants.ts index 3c5b87abba8b..be3afddf09d0 100644 --- a/frontend/src/constants/antlrQueryConstants.ts +++ b/frontend/src/constants/antlrQueryConstants.ts @@ -42,6 +42,7 @@ export const QUERY_BUILDER_FUNCTIONS = { HAS: 'has', HASANY: 'hasAny', HASALL: 'hasAll', + HASTOKEN: 'hasToken', }; export function negateOperator(operatorOrFunction: string): string { diff --git a/frontend/src/constants/dateTimeFormats.ts b/frontend/src/constants/dateTimeFormats.ts index e9a67701322c..440b640eb743 100644 --- a/frontend/src/constants/dateTimeFormats.ts +++ b/frontend/src/constants/dateTimeFormats.ts @@ -29,6 +29,7 @@ export const DATE_TIME_FORMATS = { DATE_SHORT: 'MM/DD', YEAR_SHORT: 'YY', YEAR_MONTH: 'YY-MM', + SPAN_POPOVER_DATE: 'M/D/YY - HH:mm', // Month name formats MONTH_DATE_FULL: 'MMMM DD, YYYY', diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 00721dfb4a69..6d34aa4b29c0 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -83,4 +83,7 @@ export const REACT_QUERY_KEY = { // Quick Filters Query Keys GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS', GET_OTHER_FILTERS: 'GET_OTHER_FILTERS', + SPAN_LOGS: 'SPAN_LOGS', + SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS', + SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS', } as const; diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx index 32f597a82a12..bf8d5398c21d 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannels.test.tsx @@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({ describe('Alert Channels Settings List page', () => { beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-20')); render(); await waitFor(() => expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), @@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => { }); afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); describe('Should display the Alert Channels page properly', () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => { diff --git a/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx index 162f0fb8fbdc..cbdcf223e026 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/AlertChannelsNormalUser.test.tsx @@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({ describe('Alert Channels Settings List page (Normal User)', () => { beforeEach(async () => { + jest.useFakeTimers(); render(); await waitFor(() => expect(screen.getByText('sending_channels_note')).toBeInTheDocument(), @@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => { }); afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); describe('Should display the Alert Channels page properly', () => { it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => { diff --git a/frontend/src/container/ApiMonitoring/utils.tsx b/frontend/src/container/ApiMonitoring/utils.tsx index 6026ddbb8d9e..bb68d12248ee 100644 --- a/frontend/src/container/ApiMonitoring/utils.tsx +++ b/frontend/src/container/ApiMonitoring/utils.tsx @@ -3168,7 +3168,6 @@ export const getStatusCodeBarChartWidgetData = ( }, description: '', id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2', - isStacked: false, panelTypes: PANEL_TYPES.BAR, title: '', opacity: '', diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index a52e32dd1aa5..efec53d79778 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils'; import BillingContainer from './BillingContainer'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - - const uplotMock = jest.fn(() => ({ - paths, - })); - - return { - paths, - default: uplotMock, - }; -}); - window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ @@ -67,78 +51,103 @@ describe('BillingContainer', () => { expect(currentBill).toBeInTheDocument(); }); - test('OnTrail', async () => { - await act(async () => { - render(, undefined, undefined, { - trialInfo: licensesSuccessResponse.data, + describe('Trial scenarios', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-10-20')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('OnTrail', async () => { + // Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining" + + render( + , + {}, + { appContextOverrides: { trialInfo: licensesSuccessResponse.data } }, + ); + + // If the component schedules any setTimeout on mount, flush them: + jest.runOnlyPendingTimers(); + + expect(await screen.findByText('Free Trial')).toBeInTheDocument(); + expect(await screen.findByText('billing')).toBeInTheDocument(); + expect(await screen.findByText(/\$0/i)).toBeInTheDocument(); + + expect( + await screen.findByText( + /You are in free trial period. Your free trial will end on 20 Oct 2023/i, + ), + ).toBeInTheDocument(); + + expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument(); + + const upgradeButtons = await screen.findAllByRole('button', { + name: /upgrade_plan/i, }); + expect(upgradeButtons).toHaveLength(2); + expect(upgradeButtons[1]).toBeInTheDocument(); + + expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument(); + expect( + await screen.findByRole('link', { name: /here/i }), + ).toBeInTheDocument(); }); - const freeTrailText = await screen.findByText('Free Trial'); - expect(freeTrailText).toBeInTheDocument(); - - const currentBill = await screen.findByText('billing'); - expect(currentBill).toBeInTheDocument(); - - const dollar0 = await screen.findByText(/\$0/i); - expect(dollar0).toBeInTheDocument(); - const onTrail = await screen.findByText( - /You are in free trial period. Your free trial will end on 20 Oct 2023/i, - ); - expect(onTrail).toBeInTheDocument(); - - const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i); - expect(numberOfDayRemaining).toBeInTheDocument(); - const upgradeButton = await screen.findAllByRole('button', { - name: /upgrade_plan/i, - }); - expect(upgradeButton[1]).toBeInTheDocument(); - expect(upgradeButton.length).toBe(2); - const checkPaidPlan = await screen.findByText(/checkout_plans/i); - expect(checkPaidPlan).toBeInTheDocument(); - - const link = await screen.findByRole('link', { name: /here/i }); - expect(link).toBeInTheDocument(); - }); - - test('OnTrail but trialConvertedToSubscription', async () => { - await act(async () => { - render(, undefined, undefined, { - trialInfo: trialConvertedToSubscriptionResponse.data, + test('OnTrail but trialConvertedToSubscription', async () => { + await act(async () => { + render( + , + {}, + { + appContextOverrides: { + trialInfo: trialConvertedToSubscriptionResponse.data, + }, + }, + ); }); + + const currentBill = await screen.findByText('billing'); + expect(currentBill).toBeInTheDocument(); + + const dollar0 = await screen.findByText(/\$0/i); + expect(dollar0).toBeInTheDocument(); + + const onTrail = await screen.findByText( + /You are in free trial period. Your free trial will end on 20 Oct 2023/i, + ); + expect(onTrail).toBeInTheDocument(); + + const receivedCardDetails = await screen.findByText( + /card_details_recieved_and_billing_info/i, + ); + expect(receivedCardDetails).toBeInTheDocument(); + + const manageBillingButton = await screen.findByRole('button', { + name: /manage_billing/i, + }); + expect(manageBillingButton).toBeInTheDocument(); + + const dayRemainingInBillingPeriod = await screen.findByText( + /1 days_remaining/i, + ); + expect(dayRemainingInBillingPeriod).toBeInTheDocument(); }); - - const currentBill = await screen.findByText('billing'); - expect(currentBill).toBeInTheDocument(); - - const dollar0 = await screen.findByText(/\$0/i); - expect(dollar0).toBeInTheDocument(); - - const onTrail = await screen.findByText( - /You are in free trial period. Your free trial will end on 20 Oct 2023/i, - ); - expect(onTrail).toBeInTheDocument(); - - const receivedCardDetails = await screen.findByText( - /card_details_recieved_and_billing_info/i, - ); - expect(receivedCardDetails).toBeInTheDocument(); - - const manageBillingButton = await screen.findByRole('button', { - name: /manage_billing/i, - }); - expect(manageBillingButton).toBeInTheDocument(); - - const dayRemainingInBillingPeriod = await screen.findByText( - /1 days_remaining/i, - ); - expect(dayRemainingInBillingPeriod).toBeInTheDocument(); }); test('Not on ontrail', async () => { - const { findByText } = render(, undefined, undefined, { - trialInfo: notOfTrailResponse.data, - }); + const { findByText } = render( + , + {}, + { + appContextOverrides: { + trialInfo: notOfTrailResponse.data, + }, + }, + ); const billingPeriodText = `Your current billing period is from ${getFormattedDate( billingSuccessResponse.data.billingPeriodStart, diff --git a/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx b/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx index f7a1b1fb0f07..daa68df3adbf 100644 --- a/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx +++ b/frontend/src/container/CreateAlertRule/AlertRuleDocumentationRedirection.test.tsx @@ -1,7 +1,6 @@ import ROUTES from 'constants/routes'; import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions'; import CreateAlertPage from 'pages/CreateAlert'; -import { MemoryRouter, Route } from 'react-router-dom'; import { act, fireEvent, render } from 'tests/test-utils'; import { AlertTypes } from 'types/api/alerts/alertTypes'; @@ -14,20 +13,6 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('hooks/useSafeNavigate', () => ({ useSafeNavigate: (): any => ({ safeNavigate: jest.fn(), @@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => { beforeEach(() => { act(() => { renderResult = render( - - - - - , + , + {}, + { + initialRoute: ROUTES.ALERTS_NEW, + }, ); }); }); diff --git a/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx b/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx index 783f6f658531..6b7e43a4bcd3 100644 --- a/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx +++ b/frontend/src/container/CreateAlertRule/AnomalyAlertDocumentationRedirection.test.tsx @@ -15,20 +15,6 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx new file mode 100644 index 000000000000..e45a44d2d2b4 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx @@ -0,0 +1,82 @@ +import './styles.scss'; + +import { Button, Tooltip } from 'antd'; +import classNames from 'classnames'; +import { Activity, ChartLine } from 'lucide-react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; + +import { useCreateAlertState } from '../context'; +import Stepper from '../Stepper'; +import AlertThreshold from './AlertThreshold'; +import AnomalyThreshold from './AnomalyThreshold'; +import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants'; + +function AlertCondition(): JSX.Element { + const { alertType, setAlertType } = useCreateAlertState(); + + const showMultipleTabs = + alertType === AlertTypes.ANOMALY_BASED_ALERT || + alertType === AlertTypes.METRICS_BASED_ALERT; + + const tabs = [ + { + label: 'Threshold', + icon: , + value: AlertTypes.METRICS_BASED_ALERT, + }, + ...(showMultipleTabs + ? [ + { + label: 'Anomaly', + icon: , + value: AlertTypes.ANOMALY_BASED_ALERT, + }, + ] + : []), + ]; + + const handleAlertTypeChange = (value: AlertTypes): void => { + if (!showMultipleTabs) { + return; + } + setAlertType(value); + }; + + const getTabTooltip = (tab: { value: AlertTypes }): string => { + if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) { + return ANOMALY_TAB_TOOLTIP; + } + return THRESHOLD_TAB_TOOLTIP; + }; + + return ( +
+ +
+
+ {tabs.map((tab) => ( + + + + ))} +
+
+ {alertType !== AlertTypes.ANOMALY_BASED_ALERT && } + {alertType === AlertTypes.ANOMALY_BASED_ALERT && } +
+ ); +} + +export default AlertCondition; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx new file mode 100644 index 000000000000..1610602daf2f --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx @@ -0,0 +1,162 @@ +import './styles.scss'; + +import { Button, Select, Typography } from 'antd'; +import getAllChannels from 'api/channels/getAll'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { Plus } from 'lucide-react'; +import { useQuery } from 'react-query'; +import { SuccessResponseV2 } from 'types/api'; +import { Channels } from 'types/api/channels/getAll'; +import APIError from 'types/api/error'; + +import { useCreateAlertState } from '../context'; +import { + INITIAL_INFO_THRESHOLD, + INITIAL_RANDOM_THRESHOLD, + INITIAL_WARNING_THRESHOLD, + THRESHOLD_MATCH_TYPE_OPTIONS, + THRESHOLD_OPERATOR_OPTIONS, +} from '../context/constants'; +import ThresholdItem from './ThresholdItem'; +import { UpdateThreshold } from './types'; +import { + getCategoryByOptionId, + getCategorySelectOptionByName, + getQueryNames, +} from './utils'; + +function AlertThreshold(): JSX.Element { + const { + alertState, + thresholdState, + setThresholdState, + } = useCreateAlertState(); + const { data, isLoading: isLoadingChannels } = useQuery< + SuccessResponseV2, + APIError + >(['getChannels'], { + queryFn: () => getAllChannels(), + }); + const channels = data?.data || []; + + const { currentQuery } = useQueryBuilder(); + + const queryNames = getQueryNames(currentQuery); + + const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || ''); + const categorySelectOptions = getCategorySelectOptionByName( + selectedCategory || '', + ); + + const addThreshold = (): void => { + let newThreshold; + if (thresholdState.thresholds.length === 1) { + newThreshold = INITIAL_WARNING_THRESHOLD; + } else if (thresholdState.thresholds.length === 2) { + newThreshold = INITIAL_INFO_THRESHOLD; + } else { + newThreshold = INITIAL_RANDOM_THRESHOLD; + } + setThresholdState({ + type: 'SET_THRESHOLDS', + payload: [...thresholdState.thresholds, newThreshold], + }); + }; + + const removeThreshold = (id: string): void => { + if (thresholdState.thresholds.length > 1) { + setThresholdState({ + type: 'SET_THRESHOLDS', + payload: thresholdState.thresholds.filter((t) => t.id !== id), + }); + } + }; + + const updateThreshold: UpdateThreshold = (id, field, value) => { + setThresholdState({ + type: 'SET_THRESHOLDS', + payload: thresholdState.thresholds.map((t) => + t.id === id ? { ...t, [field]: value } : t, + ), + }); + }; + + return ( +
+ {/* Main condition sentence */} +
+
+ + Send a notification when + + { + setThresholdState({ + type: 'SET_OPERATOR', + payload: value, + }); + }} + style={{ width: 120 }} + options={THRESHOLD_OPERATOR_OPTIONS} + /> + + the threshold(s) + + { + setThresholdState({ + type: 'SET_SELECTED_QUERY', + payload: value, + }); + }} + style={{ width: 80 }} + options={queryNames} + /> + + during the last + + { + updateThreshold( + thresholdState.thresholds[0].id, + 'thresholdValue', + value.toString(), + ); + }} + style={{ width: 80 }} + options={deviationOptions} + /> + + deviations + + { + setThresholdState({ + type: 'SET_MATCH_TYPE', + payload: value, + }); + }} + style={{ width: 80 }} + options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS} + /> +
+ {/* Sentence 3 */} +
+ + using the + + { + setThresholdState({ + type: 'SET_SEASONALITY', + payload: value, + }); + }} + style={{ width: 80 }} + options={ANOMALY_SEASONALITY_OPTIONS} + /> + + seasonality + +
+
+
+ ); +} + +export default AnomalyThreshold; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx new file mode 100644 index 000000000000..1d59fddbeb02 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx @@ -0,0 +1,135 @@ +import { Button, Input, Select, Space, Tooltip, Typography } from 'antd'; +import { ChartLine, CircleX } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import { ThresholdItemProps } from './types'; + +function ThresholdItem({ + threshold, + updateThreshold, + removeThreshold, + showRemoveButton, + channels, + units, +}: ThresholdItemProps): JSX.Element { + const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false); + + const yAxisUnitSelect = useMemo(() => { + let component = ( + updateThreshold(threshold.id, 'unit', value)} + style={{ width: 150 }} + options={units} + disabled={units.length === 0} + /> + + ); + } + return component; + }, [units, threshold.unit, updateThreshold, threshold.id]); + + return ( +
+
+
+
+
+ +
+ + + updateThreshold(threshold.id, 'label', e.target.value) + } + style={{ width: 260 }} + /> + + updateThreshold(threshold.id, 'thresholdValue', e.target.value) + } + style={{ width: 210 }} + /> + {yAxisUnitSelect} + +
+ to + + + updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value) + } + style={{ width: 210 }} + /> + + )} +
+ ); +} + +export default ThresholdItem; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx new file mode 100644 index 000000000000..7823616a10db --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx @@ -0,0 +1,271 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/jsx-props-no-spreading */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { MemoryRouter } from 'react-router-dom'; + +import { CreateAlertProvider } from '../../context'; +import AlertCondition from '../AlertCondition'; + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock: any = jest.fn(() => ({ + paths, + })); + uplotMock.paths = paths; + return uplotMock; +}); + +const STEPPER_TEST_ID = 'stepper'; +const ALERT_THRESHOLD_TEST_ID = 'alert-threshold'; +const ANOMALY_THRESHOLD_TEST_ID = 'anomaly-threshold'; +const THRESHOLD_VIEW_TEST_ID = 'threshold-view'; +const ANOMALY_VIEW_TEST_ID = 'anomaly-view'; +const ANOMALY_TAB_TEXT = 'Anomaly'; +const THRESHOLD_TAB_TEXT = 'Threshold'; +const ACTIVE_TAB_CLASS = '.active-tab'; + +// Mock the Stepper component +jest.mock('../../Stepper', () => ({ + __esModule: true, + default: function MockStepper({ + stepNumber, + label, + }: { + stepNumber: number; + label: string; + }): JSX.Element { + return ( +
{`Step ${stepNumber}: ${label}`}
+ ); + }, +})); + +// Mock the AlertThreshold component +jest.mock('../AlertThreshold', () => ({ + __esModule: true, + default: function MockAlertThreshold(): JSX.Element { + return ( +
Alert Threshold Component
+ ); + }, +})); + +// Mock the AnomalyThreshold component +jest.mock('../AnomalyThreshold', () => ({ + __esModule: true, + default: function MockAnomalyThreshold(): JSX.Element { + return ( +
+ Anomaly Threshold Component +
+ ); + }, +})); + +// Mock useQueryBuilder hook +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: (): { + currentQuery: { + builder: { queryData: unknown[]; queryFormulas: unknown[] }; + dataSource: string; + queryName: string; + }; + redirectWithQueryBuilderData: () => void; + } => ({ + currentQuery: { + dataSource: 'METRICS', + queryName: 'A', + builder: { + queryData: [{ dataSource: 'METRICS' }], + queryFormulas: [], + }, + }, + redirectWithQueryBuilderData: jest.fn(), + }), +})); + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderAlertCondition = ( + alertType?: string, +): ReturnType => { + const queryClient = createTestQueryClient(); + const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined; + return render( + + + + + + + , + ); +}; + +describe('AlertCondition', () => { + it('renders the stepper with correct step number and label', () => { + renderAlertCondition(); + expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent( + 'Step 2: Set alert conditions', + ); + }); + + it('verifies default props and initial state', () => { + renderAlertCondition(); + + // Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component) + expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect( + screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + ).not.toBeInTheDocument(); + + // Verify threshold tab is active by default + const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); + expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument(); + + // Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs) + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + }); + + it('renders threshold tab by default', () => { + renderAlertCondition(); + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + + // Verify default props + expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect( + screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + ).not.toBeInTheDocument(); + }); + + it('renders anomaly tab when alert type supports multiple tabs', () => { + renderAlertCondition(); + expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); + }); + + it('shows AlertThreshold component when alert type is not anomaly based', () => { + renderAlertCondition(); + expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect( + screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + ).not.toBeInTheDocument(); + }); + + it('shows AnomalyThreshold component when alert type is anomaly based', () => { + renderAlertCondition(); + + // Click on anomaly tab to switch to anomaly-based alert + const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT); + fireEvent.click(anomalyTab); + + expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument(); + }); + + it('switches between threshold and anomaly tabs', () => { + renderAlertCondition(); + + // Initially shows threshold component + expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); + + // Click anomaly tab + const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT); + fireEvent.click(anomalyTab); + + // Should show anomaly component + expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument(); + + // Click threshold tab + const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); + fireEvent.click(thresholdTab); + + // Should show threshold component again + expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); + expect( + screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + ).not.toBeInTheDocument(); + }); + + it('applies active tab styling correctly', () => { + renderAlertCondition(); + + const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); + const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT); + + // Threshold tab should be active by default + expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument(); + expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument(); + + // Click anomaly tab + fireEvent.click(anomalyTab); + + // Anomaly tab should be active now + expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument(); + expect(thresholdTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument(); + }); + + it('shows multiple tabs for METRICS_BASED_ALERT', () => { + renderAlertCondition('METRIC_BASED_ALERT'); + + // Both tabs should be visible + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); + }); + + it('shows multiple tabs for ANOMALY_BASED_ALERT', () => { + renderAlertCondition('ANOMALY_BASED_ALERT'); + + // Both tabs should be visible + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); + }); + + it('shows only threshold tab for LOGS_BASED_ALERT', () => { + renderAlertCondition('LOGS_BASED_ALERT'); + + // Only threshold tab should be visible + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument(); + }); + + it('shows only threshold tab for TRACES_BASED_ALERT', () => { + renderAlertCondition('TRACES_BASED_ALERT'); + + // Only threshold tab should be visible + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument(); + }); + + it('shows only threshold tab for EXCEPTIONS_BASED_ALERT', () => { + renderAlertCondition('EXCEPTIONS_BASED_ALERT'); + + // Only threshold tab should be visible + expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); + expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument(); + expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(ANOMALY_VIEW_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx new file mode 100644 index 000000000000..dd4b6385f343 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/jsx-props-no-spreading */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { Channels } from 'types/api/channels/getAll'; + +import { CreateAlertProvider } from '../../context'; +import AlertThreshold from '../AlertThreshold'; + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock: any = jest.fn(() => ({ + paths, + })); + uplotMock.paths = paths; + return uplotMock; +}); + +// Mock the ThresholdItem component +jest.mock('../ThresholdItem', () => ({ + __esModule: true, + default: function MockThresholdItem({ + threshold, + removeThreshold, + showRemoveButton, + }: { + threshold: Record; + removeThreshold: (id: string) => void; + showRemoveButton: boolean; + }): JSX.Element { + return ( +
+ {threshold.label as string} + {showRemoveButton && ( + + )} +
+ ); + }, +})); + +// Mock useQueryBuilder hook +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: (): { + currentQuery: { + dataSource: string; + queryName: string; + builder: { + queryData: Array<{ queryName: string }>; + queryFormulas: Array<{ queryName: string }>; + }; + unit: string; + }; + } => ({ + currentQuery: { + dataSource: 'METRICS', + queryName: 'A', + builder: { + queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }], + queryFormulas: [{ queryName: 'Formula 1' }], + }, + unit: 'bytes', + }, + }), +})); + +// Mock getAllChannels API +jest.mock('api/channels/getAll', () => ({ + __esModule: true, + default: jest.fn(() => + Promise.resolve({ + data: [ + { id: '1', name: 'Email Channel' }, + { id: '2', name: 'Slack Channel' }, + ] as Channels[], + }), + ), +})); + +// Mock alert format categories +jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({ + getCategoryByOptionId: jest.fn(() => ({ name: 'bytes' })), + getCategorySelectOptionByName: jest.fn(() => [ + { label: 'Bytes', value: 'bytes' }, + { label: 'KB', value: 'kb' }, + ]), +})); + +const TEST_STRINGS = { + ADD_THRESHOLD: 'Add Threshold', + AT_LEAST_ONCE: 'AT LEAST ONCE', + IS_ABOVE: 'IS ABOVE', +} as const; + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderAlertThreshold = (): ReturnType => { + const queryClient = createTestQueryClient(); + return render( + + + + + + + , + ); +}; + +const verifySelectRenders = (title: string): void => { + const select = screen.getByTitle(title); + expect(select).toBeInTheDocument(); +}; + +describe('AlertThreshold', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the main condition sentence', () => { + renderAlertThreshold(); + expect(screen.getByText('Send a notification when')).toBeInTheDocument(); + expect(screen.getByText('the threshold(s)')).toBeInTheDocument(); + expect(screen.getByText('during the')).toBeInTheDocument(); + expect(screen.getByText('Evaluation Window.')).toBeInTheDocument(); + }); + + it('renders query selection dropdown', async () => { + renderAlertThreshold(); + + await waitFor(() => { + const querySelect = screen.getByTitle('A'); + expect(querySelect).toBeInTheDocument(); + }); + }); + + it('renders operator selection dropdown', () => { + renderAlertThreshold(); + verifySelectRenders(TEST_STRINGS.IS_ABOVE); + }); + + it('renders match type selection dropdown', () => { + renderAlertThreshold(); + verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE); + }); + + it('renders threshold items', () => { + renderAlertThreshold(); + expect(screen.getByTestId(/threshold-item-/)).toBeInTheDocument(); + }); + + it('renders add threshold button', () => { + renderAlertThreshold(); + expect(screen.getByText(TEST_STRINGS.ADD_THRESHOLD)).toBeInTheDocument(); + }); + + it('adds a new threshold when add button is clicked', () => { + renderAlertThreshold(); + + const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD); + fireEvent.click(addButton); + + // Should now have multiple threshold items + const thresholdItems = screen.getAllByTestId(/threshold-item-/); + expect(thresholdItems).toHaveLength(2); + }); + + it('adds correct threshold types based on count', () => { + renderAlertThreshold(); + + const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD); + + // First addition should add WARNING threshold + fireEvent.click(addButton); + expect(screen.getByText('WARNING')).toBeInTheDocument(); + + // Second addition should add INFO threshold + fireEvent.click(addButton); + expect(screen.getByText('INFO')).toBeInTheDocument(); + + // Third addition should add random threshold + fireEvent.click(addButton); + expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(4); + }); + + it('updates operator when operator dropdown changes', () => { + renderAlertThreshold(); + verifySelectRenders(TEST_STRINGS.IS_ABOVE); + }); + + it('updates match type when match type dropdown changes', () => { + renderAlertThreshold(); + verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE); + }); + + it('shows remove button for non-first thresholds', () => { + renderAlertThreshold(); + + // Add a threshold + const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD); + fireEvent.click(addButton); + + // Second threshold should have remove button + expect(screen.getByTestId(/remove-threshold-/)).toBeInTheDocument(); + }); + + it('does not show remove button for first threshold', () => { + renderAlertThreshold(); + + // First threshold should not have remove button + expect(screen.queryByTestId(/remove-threshold-/)).not.toBeInTheDocument(); + }); + + it('removes threshold when remove button is clicked', () => { + renderAlertThreshold(); + + // Add a threshold first + const addButton = screen.getByText(TEST_STRINGS.ADD_THRESHOLD); + fireEvent.click(addButton); + + // Get the remove button and click it + const removeButton = screen.getByTestId(/remove-threshold-/); + fireEvent.click(removeButton); + + // Should be back to one threshold + expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1); + }); + + it('does not remove threshold if only one remains', () => { + renderAlertThreshold(); + + // Should only have one threshold initially + expect(screen.getAllByTestId(/threshold-item-/)).toHaveLength(1); + + // Try to remove (should not work) + const thresholdItems = screen.getAllByTestId(/threshold-item-/); + expect(thresholdItems).toHaveLength(1); + }); + + it('handles loading state for channels', () => { + renderAlertThreshold(); + + // Component should render even while channels are loading + expect(screen.getByText('Send a notification when')).toBeInTheDocument(); + }); + + it('renders with correct initial state', () => { + renderAlertThreshold(); + + // Should have initial critical threshold + expect(screen.getByText('CRITICAL')).toBeInTheDocument(); + verifySelectRenders(TEST_STRINGS.IS_ABOVE); + verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx new file mode 100644 index 000000000000..6729519830f5 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx @@ -0,0 +1,89 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } from '@testing-library/react'; +import { + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, +} from 'container/CreateAlertV2/context/constants'; + +import * as context from '../../context'; +import AnomalyThreshold from '../AnomalyThreshold'; + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock: any = jest.fn(() => ({ + paths, + })); + uplotMock.paths = paths; + return uplotMock; +}); + +const mockSetAlertState = jest.fn(); +const mockSetThresholdState = jest.fn(); +jest.spyOn(context, 'useCreateAlertState').mockReturnValue({ + alertState: INITIAL_ALERT_STATE, + setAlertState: mockSetAlertState, + thresholdState: INITIAL_ALERT_THRESHOLD_STATE, + setThresholdState: mockSetThresholdState, +} as any); + +// Mock useQueryBuilder hook +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: (): { + currentQuery: { + dataSource: string; + queryName: string; + builder: { + queryData: Array<{ queryName: string }>; + queryFormulas: Array<{ queryName: string }>; + }; + }; + } => ({ + currentQuery: { + dataSource: 'METRICS', + queryName: 'A', + builder: { + queryData: [{ queryName: 'Query A' }, { queryName: 'Query B' }], + queryFormulas: [{ queryName: 'Formula 1' }], + }, + }, + }), +})); + +const renderAnomalyThreshold = (): ReturnType => + render(); + +describe('AnomalyThreshold', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the first condition sentence', () => { + renderAnomalyThreshold(); + expect(screen.getByTestId('notification-text')).toBeInTheDocument(); + expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument(); + expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument(); + }); + + it('renders the second condition sentence', () => { + renderAnomalyThreshold(); + expect(screen.getByTestId('threshold-text')).toBeInTheDocument(); + expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument(); + expect(screen.getByTestId('deviations-text')).toBeInTheDocument(); + expect(screen.getByTestId('operator-select')).toBeInTheDocument(); + expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument(); + expect(screen.getByTestId('match-type-select')).toBeInTheDocument(); + }); + + it('renders the third condition sentence', () => { + renderAnomalyThreshold(); + expect(screen.getByTestId('using-the-text')).toBeInTheDocument(); + expect(screen.getByTestId('algorithm-select')).toBeInTheDocument(); + expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument(); + expect(screen.getByTestId('seasonality-select')).toBeInTheDocument(); + expect(screen.getByTestId('seasonality-text')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx new file mode 100644 index 000000000000..01fcbf97ce92 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx @@ -0,0 +1,398 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/jsx-props-no-spreading */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { DefaultOptionType } from 'antd/es/select'; +import { Channels } from 'types/api/channels/getAll'; + +import ThresholdItem from '../ThresholdItem'; +import { ThresholdItemProps } from '../types'; + +// Mock the enableRecoveryThreshold utility +jest.mock('../../utils', () => ({ + enableRecoveryThreshold: jest.fn(() => true), +})); + +const TEST_CONSTANTS = { + THRESHOLD_ID: 'test-threshold-1', + CRITICAL_LABEL: 'CRITICAL', + WARNING_LABEL: 'WARNING', + INFO_LABEL: 'INFO', + CHANNEL_1: 'channel-1', + CHANNEL_2: 'channel-2', + CHANNEL_3: 'channel-3', + EMAIL_CHANNEL_NAME: 'Email Channel', + ENTER_THRESHOLD_NAME: 'Enter threshold name', + ENTER_THRESHOLD_VALUE: 'Enter threshold value', + ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value', +} as const; + +const mockThreshold = { + id: TEST_CONSTANTS.THRESHOLD_ID, + label: TEST_CONSTANTS.CRITICAL_LABEL, + thresholdValue: 100, + recoveryThresholdValue: 80, + unit: 'bytes', + channels: [TEST_CONSTANTS.CHANNEL_1], + color: '#ff0000', +}; + +const mockChannels: Channels[] = [ + { + id: TEST_CONSTANTS.CHANNEL_1, + name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME, + } as any, + { id: TEST_CONSTANTS.CHANNEL_2, name: 'Slack Channel' } as any, + { id: TEST_CONSTANTS.CHANNEL_3, name: 'PagerDuty Channel' } as any, +]; + +const mockUnits: DefaultOptionType[] = [ + { label: 'Bytes', value: 'bytes' }, + { label: 'KB', value: 'kb' }, + { label: 'MB', value: 'mb' }, +]; + +const defaultProps: ThresholdItemProps = { + threshold: mockThreshold, + updateThreshold: jest.fn(), + removeThreshold: jest.fn(), + showRemoveButton: false, + channels: mockChannels, + isLoadingChannels: false, + units: mockUnits, +}; + +const renderThresholdItem = ( + props: Partial = {}, +): ReturnType => { + const mergedProps = { ...defaultProps, ...props }; + return render(); +}; + +const verifySelectorWidth = ( + selectorIndex: number, + expectedWidth: string, +): void => { + const selectors = screen.getAllByRole('combobox'); + const selector = selectors[selectorIndex]; + expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`); +}; + +const showRecoveryThreshold = (): void => { + const recoveryButton = screen.getByRole('button', { name: '' }); + fireEvent.click(recoveryButton); +}; + +const verifyComponentRendersWithLoading = (): void => { + expect( + screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_THRESHOLD_NAME), + ).toBeInTheDocument(); +}; + +const verifyUnitSelectorDisabled = (): void => { + const unitSelectors = screen.getAllByRole('combobox'); + const unitSelector = unitSelectors[0]; // First combobox is the unit selector + expect(unitSelector).toBeDisabled(); +}; + +describe('ThresholdItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders threshold indicator with correct color', () => { + renderThresholdItem(); + + // Find the threshold dot by its class + const thresholdDot = document.querySelector('.threshold-dot'); + expect(thresholdDot).toHaveStyle('background-color: #ff0000'); + }); + + it('renders threshold label input with correct value', () => { + renderThresholdItem(); + + const labelInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_NAME, + ); + expect(labelInput).toHaveValue(TEST_CONSTANTS.CRITICAL_LABEL); + }); + + it('renders threshold value input with correct value', () => { + renderThresholdItem(); + + const valueInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, + ); + expect(valueInput).toHaveValue('100'); + }); + + it('renders unit selector with correct value', () => { + renderThresholdItem(); + + // Check for the unit selector by looking for the displayed text + expect(screen.getByText('Bytes')).toBeInTheDocument(); + }); + + it('renders channels selector with correct value', () => { + renderThresholdItem(); + + // Check for the channels selector by looking for the displayed text + expect( + screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), + ).toBeInTheDocument(); + }); + + it('updates threshold label when label input changes', () => { + const updateThreshold = jest.fn(); + renderThresholdItem({ updateThreshold }); + + const labelInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_NAME, + ); + fireEvent.change(labelInput, { + target: { value: TEST_CONSTANTS.WARNING_LABEL }, + }); + + expect(updateThreshold).toHaveBeenCalledWith( + TEST_CONSTANTS.THRESHOLD_ID, + 'label', + TEST_CONSTANTS.WARNING_LABEL, + ); + }); + + it('updates threshold value when value input changes', () => { + const updateThreshold = jest.fn(); + renderThresholdItem({ updateThreshold }); + + const valueInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, + ); + fireEvent.change(valueInput, { target: { value: '200' } }); + + expect(updateThreshold).toHaveBeenCalledWith( + TEST_CONSTANTS.THRESHOLD_ID, + 'thresholdValue', + '200', + ); + }); + + it('updates threshold unit when unit selector changes', () => { + const updateThreshold = jest.fn(); + renderThresholdItem({ updateThreshold }); + + // Find the unit selector by its role and simulate change + const unitSelectors = screen.getAllByRole('combobox'); + const unitSelector = unitSelectors[0]; // First combobox is the unit selector + + // Simulate clicking to open the dropdown and selecting a value + fireEvent.click(unitSelector); + + // The actual change event might not work the same way with Ant Design Select + // So we'll just verify the selector is present and can be interacted with + expect(unitSelector).toBeInTheDocument(); + }); + + it('updates threshold channels when channels selector changes', () => { + const updateThreshold = jest.fn(); + renderThresholdItem({ updateThreshold }); + + // Find the channels selector by its role and simulate change + const channelSelectors = screen.getAllByRole('combobox'); + const channelSelector = channelSelectors[1]; // Second combobox is the channels selector + + // Simulate clicking to open the dropdown + fireEvent.click(channelSelector); + + // The actual change event might not work the same way with Ant Design Select + // So we'll just verify the selector is present and can be interacted with + expect(channelSelector).toBeInTheDocument(); + }); + + it('shows remove button when showRemoveButton is true', () => { + renderThresholdItem({ showRemoveButton: true }); + + // The remove button is the second button (with circle-x icon) + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); // Recovery button + remove button + }); + + it('does not show remove button when showRemoveButton is false', () => { + renderThresholdItem({ showRemoveButton: false }); + + // Only the recovery button should be present + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); // Only recovery button + }); + + it('calls removeThreshold when remove button is clicked', () => { + const removeThreshold = jest.fn(); + renderThresholdItem({ showRemoveButton: true, removeThreshold }); + + // The remove button is the second button (with circle-x icon) + const buttons = screen.getAllByRole('button'); + const removeButton = buttons[1]; // Second button is the remove button + fireEvent.click(removeButton); + + expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID); + }); + + it('shows recovery threshold button when recovery threshold is enabled', () => { + renderThresholdItem(); + + // The recovery button is the first button (with chart-line icon) + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); // Recovery button + }); + + it('shows recovery threshold inputs when recovery button is clicked', () => { + renderThresholdItem(); + + // The recovery button is the first button (with chart-line icon) + const buttons = screen.getAllByRole('button'); + const recoveryButton = buttons[0]; // First button is the recovery button + fireEvent.click(recoveryButton); + + expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE), + ).toBeInTheDocument(); + }); + + it('updates recovery threshold value when input changes', () => { + const updateThreshold = jest.fn(); + renderThresholdItem({ updateThreshold }); + + // Show recovery threshold first + const buttons = screen.getAllByRole('button'); + const recoveryButton = buttons[0]; // First button is the recovery button + fireEvent.click(recoveryButton); + + const recoveryValueInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE, + ); + fireEvent.change(recoveryValueInput, { target: { value: '90' } }); + + expect(updateThreshold).toHaveBeenCalledWith( + TEST_CONSTANTS.THRESHOLD_ID, + 'recoveryThresholdValue', + '90', + ); + }); + + it('disables unit selector when no units are available', () => { + renderThresholdItem({ units: [] }); + verifyUnitSelectorDisabled(); + }); + + it('shows tooltip when no units are available', () => { + renderThresholdItem({ units: [] }); + + // The tooltip should be present when hovering over disabled unit selector + verifyUnitSelectorDisabled(); + }); + + it('renders channels as multiple select options', () => { + renderThresholdItem(); + + // Check that channels are rendered as multiple select + expect( + screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), + ).toBeInTheDocument(); + + // Should be able to select multiple channels + const channelSelectors = screen.getAllByRole('combobox'); + const channelSelector = channelSelectors[1]; // Second combobox is the channels selector + fireEvent.change(channelSelector, { + target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] }, + }); + }); + + it('handles empty threshold values correctly', () => { + const emptyThreshold = { + ...mockThreshold, + label: '', + thresholdValue: 0, + unit: '', + channels: [], + }; + + renderThresholdItem({ threshold: emptyThreshold }); + + expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue(''); + expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0'); + }); + + it('renders with correct input widths', () => { + renderThresholdItem(); + + const labelInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_NAME, + ); + const valueInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, + ); + + expect(labelInput).toHaveStyle('width: 260px'); + expect(valueInput).toHaveStyle('width: 210px'); + }); + + it('renders channels selector with correct width', () => { + renderThresholdItem(); + verifySelectorWidth(1, '260px'); + }); + + it('renders unit selector with correct width', () => { + renderThresholdItem(); + verifySelectorWidth(0, '150px'); + }); + + it('handles loading channels state', () => { + renderThresholdItem({ isLoadingChannels: true }); + verifyComponentRendersWithLoading(); + }); + + it('renders recovery threshold with correct initial value', () => { + renderThresholdItem(); + showRecoveryThreshold(); + + const recoveryValueInput = screen.getByPlaceholderText( + TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE, + ); + expect(recoveryValueInput).toHaveValue('80'); + }); + + it('renders recovery threshold label as disabled', () => { + renderThresholdItem(); + showRecoveryThreshold(); + + const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold'); + expect(recoveryLabelInput).toBeDisabled(); + }); + + it('renders correct channel options', () => { + renderThresholdItem(); + + // Check that channels are rendered + expect( + screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), + ).toBeInTheDocument(); + + // Should be able to select different channels + const channelSelectors = screen.getAllByRole('combobox'); + const channelSelector = channelSelectors[1]; // Second combobox is the channels selector + fireEvent.change(channelSelector, { target: { value: 'channel-2' } }); + expect(screen.getByText('Slack Channel')).toBeInTheDocument(); + }); + + it('handles threshold without channels', () => { + const thresholdWithoutChannels = { + ...mockThreshold, + channels: [], + }; + + renderThresholdItem({ threshold: thresholdWithoutChannels }); + + // Should render channels selector without selected values + const channelSelectors = screen.getAllByRole('combobox'); + expect(channelSelectors).toHaveLength(2); // Should have both unit and channel selectors + }); +}); diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/constants.ts b/frontend/src/container/CreateAlertV2/AlertCondition/constants.ts new file mode 100644 index 000000000000..7ca78ae3ca33 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/constants.ts @@ -0,0 +1,5 @@ +export const THRESHOLD_TAB_TOOLTIP = + 'An alert is triggered when the metric crosses a threshold.'; + +export const ANOMALY_TAB_TOOLTIP = + 'An alert is triggered whenever the metric deviates from an expected pattern.'; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/index.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/index.tsx new file mode 100644 index 000000000000..f33f67e32bd2 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/index.tsx @@ -0,0 +1,3 @@ +import AlertCondition from './AlertCondition'; + +export default AlertCondition; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss new file mode 100644 index 000000000000..bdde72598d6a --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss @@ -0,0 +1,277 @@ +.alert-condition-container { + margin: 0 16px; + margin-top: 24px; + + .alert-condition { + display: flex; + align-items: center; + margin-left: 12px; + margin-top: 24px; + + .alert-condition-tabs { + display: flex; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + flex-direction: row; + border-bottom: none; + margin-bottom: -1px; + + .explorer-view-option { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + border: none; + padding: 9px; + box-shadow: none; + border-radius: 0px; + border-left: 0.5px solid var(--bg-slate-400); + border-bottom: 0.5px solid var(--bg-slate-400); + width: 120px; + height: 36px; + + gap: 8px; + + &.active-tab { + background-color: var(--bg-ink-500); + border-bottom: none; + + &:hover { + background-color: var(--bg-ink-500) !important; + } + } + + &:disabled { + background-color: var(--bg-ink-300); + opacity: 0.6; + } + + &:first-child { + border-left: 1px solid transparent; + } + + &:hover { + background-color: transparent !important; + border-left: 1px solid transparent !important; + color: var(--bg-vanilla-100); + } + } + } + } +} + +.alert-threshold-container, +.anomaly-threshold-container { + padding: 24px; + padding-right: 72px; + background-color: var(--bg-ink-500); + border: 1px solid var(--bg-slate-400); + width: fit-content; + + .alert-condition-sentences { + display: flex; + flex-direction: column; + gap: 12px; + + .alert-condition-sentence { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + + .sentence-text { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 1.5; + } + + .ant-select { + width: 240px !important; + + .ant-select-selector { + background-color: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); + color: var(--text-vanilla-400); + font-family: 'Space Mono'; + + &:hover { + border-color: var(--bg-vanilla-300); + } + + &:focus { + border-color: var(--bg-vanilla-300); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + } + } + + .ant-select-selection-item { + color: var(--bg-vanilla-100); + } + + .ant-select-arrow { + color: var(--bg-vanilla-400); + } + } + } + } + + .thresholds-section { + margin-top: 16px; + margin-left: 24px; + + .threshold-item { + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 16px; + + .threshold-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 2px; + + .threshold-indicator { + .threshold-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + } + } + + .threshold-controls { + display: flex; + align-items: center; + gap: 8px; + + .ant-input { + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-slate-400); + color: var(--bg-vanilla-100); + height: 32px; + + &::placeholder { + font-family: 'Space Mono'; + } + + &:hover { + border-color: var(--bg-vanilla-300); + } + + &:focus { + border-color: var(--bg-vanilla-300); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + } + } + + .ant-select { + .ant-select-selector { + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-slate-400); + color: var(--bg-vanilla-100); + height: 32px; + + &:hover { + border-color: var(--bg-vanilla-300); + } + + &:focus { + border-color: var(--bg-vanilla-300); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + } + + .ant-select-selection-placeholder { + font-family: 'Space Mono'; + } + } + + .ant-select-selection-item { + color: var(--bg-vanilla-100); + } + + .ant-select-arrow { + color: var(--bg-vanilla-400); + } + } + + .icon-btn { + color: var(--bg-vanilla-400); + border: 1px solid var(--bg-slate-400); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + } + } + + .recovery-threshold-input-group { + display: flex; + align-items: center; + gap: 0; + margin-left: 28px; + + .recovery-threshold-label { + pointer-events: none; + cursor: default; + } + + .recovery-threshold-btn { + pointer-events: none; + cursor: default; + color: var(--bg-vanilla-400); + background-color: var(--bg-ink-400) !important; + border: 1px solid var(--bg-slate-400); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + + .ant-input { + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-slate-400); + color: var(--bg-vanilla-100); + height: 32px; + + &::placeholder { + font-family: 'Space Mono'; + } + + &:hover { + border-color: var(--bg-vanilla-300); + } + + &:focus { + border-color: var(--bg-vanilla-300); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + } + } + } + } + + .add-threshold-btn { + margin-top: 8px; + border: 1px dashed var(--bg-slate-400); + color: var(--bg-vanilla-300); + background-color: transparent; + border-radius: 4px; + height: 32px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + border-color: var(--bg-vanilla-300); + color: var(--bg-vanilla-100); + } + + .anticon { + margin-right: 8px; + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/types.ts b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts new file mode 100644 index 000000000000..382955b58a10 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts @@ -0,0 +1,23 @@ +import { DefaultOptionType } from 'antd/es/select'; +import { Channels } from 'types/api/channels/getAll'; + +import { Threshold } from '../context/types'; + +export type UpdateThreshold = { + (thresholdId: string, field: 'channels', value: string[]): void; + ( + thresholdId: string, + field: Exclude, + value: string, + ): void; +}; + +export interface ThresholdItemProps { + threshold: Threshold; + updateThreshold: UpdateThreshold; + removeThreshold: (thresholdId: string) => void; + showRemoveButton: boolean; + channels: Channels[]; + isLoadingChannels: boolean; + units: DefaultOptionType[]; +} diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx new file mode 100644 index 000000000000..af4d89c47f51 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx @@ -0,0 +1,46 @@ +import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select'; +import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils'; +import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants'; +import { getSelectedQueryOptions } from 'container/FormAlertRules/utils'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { EQueryType } from 'types/common/dashboard'; + +export function getQueryNames(currentQuery: Query): BaseOptionType[] { + const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator( + currentQuery.builder.queryTraceOperator, + ); + const queryConfig: Record SelectProps['options']> = { + [EQueryType.QUERY_BUILDER]: () => [ + ...(getSelectedQueryOptions(currentQuery.builder.queryData)?.filter( + (option) => + !involvedQueriesInTraceOperator.includes(option.value as string), + ) || []), + ...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []), + ...(getSelectedQueryOptions(currentQuery.builder.queryTraceOperator) || []), + ], + [EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql), + [EQueryType.CLICKHOUSE]: () => + getSelectedQueryOptions(currentQuery.clickhouse_sql), + }; + + return queryConfig[currentQuery.queryType]?.() || []; +} + +export function getCategoryByOptionId(id: string): string | undefined { + return Y_AXIS_CATEGORIES.find((category) => + category.units.some((unit) => unit.id === id), + )?.name; +} + +export function getCategorySelectOptionByName( + name: string, +): DefaultOptionType[] { + return ( + Y_AXIS_CATEGORIES.find((category) => category.name === name)?.units.map( + (unit) => ({ + label: unit.name, + value: unit.id, + }), + ) || [] + ); +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx new file mode 100644 index 000000000000..8ad31b7c5981 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx @@ -0,0 +1,73 @@ +import './styles.scss'; + +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useCallback, useMemo } from 'react'; +import { Labels } from 'types/api/alerts/def'; + +import { useCreateAlertState } from '../context'; +import LabelsInput from './LabelsInput'; + +function CreateAlertHeader(): JSX.Element { + const { alertState, setAlertState } = useCreateAlertState(); + + const { currentQuery } = useQueryBuilder(); + + const groupByLabels = useMemo(() => { + const labels = new Array(); + currentQuery.builder.queryData.forEach((query) => { + query.groupBy.forEach((groupBy) => { + labels.push(groupBy.key); + }); + }); + return labels; + }, [currentQuery]); + + // If the label key is a group by label, then it is not allowed to be used as a label key + const validateLabelsKey = useCallback( + (key: string): string | null => { + if (groupByLabels.includes(key)) { + return `Cannot use ${key} as a key`; + } + return null; + }, + [groupByLabels], + ); + + return ( +
+
+
New Alert Rule
+
+ +
+ + setAlertState({ type: 'SET_ALERT_NAME', payload: e.target.value }) + } + className="alert-header__input title" + placeholder="Enter alert rule name" + /> + + setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value }) + } + className="alert-header__input description" + placeholder="Click to add description..." + /> + + setAlertState({ type: 'SET_ALERT_LABELS', payload: labels }) + } + validateLabelsKey={validateLabelsKey} + /> +
+
+ ); +} + +export default CreateAlertHeader; diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/LabelsInput.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/LabelsInput.tsx new file mode 100644 index 000000000000..31c72eca7d6a --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/LabelsInput.tsx @@ -0,0 +1,168 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { useNotifications } from 'hooks/useNotifications'; +import React, { useCallback, useState } from 'react'; + +import { LabelInputState, LabelsInputProps } from './types'; + +function LabelsInput({ + labels, + onLabelsChange, + validateLabelsKey, +}: LabelsInputProps): JSX.Element { + const { notifications } = useNotifications(); + const [inputState, setInputState] = useState({ + key: '', + value: '', + isKeyInput: true, + }); + const [isAdding, setIsAdding] = useState(false); + + const handleAddLabelsClick = useCallback(() => { + setIsAdding(true); + setInputState({ key: '', value: '', isKeyInput: true }); + }, []); + + const handleKeyDown = useCallback( + // eslint-disable-next-line sonarjs/cognitive-complexity + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (inputState.isKeyInput) { + // Check if input contains a colon (key:value format) + if (inputState.key.includes(':')) { + const [key, ...valueParts] = inputState.key.split(':'); + const value = valueParts.join(':'); // Rejoin in case value contains colons + + if (key.trim() && value.trim()) { + if (labels[key.trim()]) { + notifications.error({ + message: 'Label with this key already exists', + }); + return; + } + const error = validateLabelsKey(key.trim()); + if (error) { + notifications.error({ + message: error, + }); + return; + } + // Add the label immediately + const newLabels = { + ...labels, + [key.trim()]: value.trim(), + }; + onLabelsChange(newLabels); + + // Reset input state + setInputState({ key: '', value: '', isKeyInput: true }); + } + } else if (inputState.key.trim()) { + if (labels[inputState.key.trim()]) { + notifications.error({ + message: 'Label with this key already exists', + }); + return; + } + const error = validateLabelsKey(inputState.key.trim()); + if (error) { + notifications.error({ + message: error, + }); + return; + } + setInputState((prev) => ({ ...prev, isKeyInput: false })); + } + } else if (inputState.value.trim()) { + // Add the label + const newLabels = { + ...labels, + [inputState.key.trim()]: inputState.value.trim(), + }; + onLabelsChange(newLabels); + + // Reset and continue adding + setInputState({ key: '', value: '', isKeyInput: true }); + } + } else if (e.key === 'Escape') { + // Cancel adding + setIsAdding(false); + setInputState({ key: '', value: '', isKeyInput: true }); + } + }, + [inputState, labels, notifications, onLabelsChange, validateLabelsKey], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + if (inputState.isKeyInput) { + setInputState((prev) => ({ ...prev, key: e.target.value })); + } else { + setInputState((prev) => ({ ...prev, value: e.target.value })); + } + }, + [inputState.isKeyInput], + ); + + const handleRemoveLabel = useCallback( + (key: string) => { + const newLabels = { ...labels }; + delete newLabels[key]; + onLabelsChange(newLabels); + }, + [labels, onLabelsChange], + ); + + const handleBlur = useCallback(() => { + if (!inputState.key && !inputState.value) { + setIsAdding(false); + setInputState({ key: '', value: '', isKeyInput: true }); + } + }, [inputState]); + + return ( +
+ {Object.keys(labels).length > 0 && ( +
+ {Object.entries(labels).map(([key, value]) => ( + + {key}: {value} + + + ))} +
+ )} + + {!isAdding ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +export default LabelsInput; diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx new file mode 100644 index 000000000000..adb4e8ed8b97 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { CreateAlertProvider } from '../../context'; +import CreateAlertHeader from '../CreateAlertHeader'; + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: (): { search: string } => ({ + search: '', + }), +})); + +const renderCreateAlertHeader = (): ReturnType => + render( + + + , + ); + +describe('CreateAlertHeader', () => { + it('renders the header with title', () => { + renderCreateAlertHeader(); + expect(screen.getByText('New Alert Rule')).toBeInTheDocument(); + }); + + it('renders name input with placeholder', () => { + renderCreateAlertHeader(); + const nameInput = screen.getByPlaceholderText('Enter alert rule name'); + expect(nameInput).toBeInTheDocument(); + }); + + it('renders description input with placeholder', () => { + renderCreateAlertHeader(); + const descriptionInput = screen.getByPlaceholderText( + 'Click to add description...', + ); + expect(descriptionInput).toBeInTheDocument(); + }); + + it('renders LabelsInput component', () => { + renderCreateAlertHeader(); + expect(screen.getByText('+ Add labels')).toBeInTheDocument(); + }); + + it('updates name when typing in name input', () => { + renderCreateAlertHeader(); + const nameInput = screen.getByPlaceholderText('Enter alert rule name'); + + fireEvent.change(nameInput, { target: { value: 'Test Alert' } }); + + expect(nameInput).toHaveValue('Test Alert'); + }); + + it('updates description when typing in description input', () => { + renderCreateAlertHeader(); + const descriptionInput = screen.getByPlaceholderText( + 'Click to add description...', + ); + fireEvent.change(descriptionInput, { target: { value: 'Test Description' } }); + expect(descriptionInput).toHaveValue('Test Description'); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/LabelsInput.test.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/LabelsInput.test.tsx new file mode 100644 index 000000000000..15cb6a083c1b --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/LabelsInput.test.tsx @@ -0,0 +1,510 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { fireEvent, render, screen } from '@testing-library/react'; + +import LabelsInput from '../LabelsInput'; +import { LabelsInputProps } from '../types'; + +// Mock the CloseOutlined icon +jest.mock('@ant-design/icons', () => ({ + CloseOutlined: (): JSX.Element => ×, +})); + +const mockOnLabelsChange = jest.fn(); +const mockValidateLabelsKey = jest.fn().mockReturnValue(null); + +const defaultProps: LabelsInputProps = { + labels: {}, + onLabelsChange: mockOnLabelsChange, + validateLabelsKey: mockValidateLabelsKey, +}; + +const ADD_LABELS_TEXT = '+ Add labels'; +const ENTER_KEY_PLACEHOLDER = 'Enter key'; +const ENTER_VALUE_PLACEHOLDER = 'Enter value'; + +const CLOSE_ICON_TEST_ID = 'close-icon'; +const SEVERITY_HIGH_TEXT = 'severity: high'; +const ENVIRONMENT_PRODUCTION_TEXT = 'environment: production'; +const SEVERITY_HIGH_KEY_VALUE = 'severity:high'; + +const renderLabelsInput = ( + props: Partial = {}, +): ReturnType => + render(); + +describe('LabelsInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateLabelsKey.mockReturnValue(null); // Reset validation to always pass + }); + + describe('Initial Rendering', () => { + it('renders add button when no labels exist', () => { + renderLabelsInput(); + expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument(); + expect(screen.queryByTestId(CLOSE_ICON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('renders existing labels when provided', () => { + const labels = { severity: 'high', environment: 'production' }; + renderLabelsInput({ labels }); + + expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument(); + expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument(); + expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(2); + }); + + it('does not render existing labels section when no labels', () => { + renderLabelsInput(); + expect(screen.queryByText(SEVERITY_HIGH_TEXT)).not.toBeInTheDocument(); + }); + }); + + describe('Adding Labels', () => { + it('shows input field when add button is clicked', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + + expect( + screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).toBeInTheDocument(); + expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument(); + }); + + it('switches from key input to value input on Enter', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect( + screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + + it('adds label when both key and value are provided', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Enter value + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.change(valueInput, { target: { value: 'high' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' }); + }); + + it('does not switch to value input if key is empty', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect( + screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + + it('does not add label if value is empty', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Try to add with empty value + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + expect(mockOnLabelsChange).not.toHaveBeenCalled(); + }); + + it('trims whitespace from key and value', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key with whitespace + fireEvent.change(input, { target: { value: ' severity ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Enter value with whitespace + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.change(valueInput, { target: { value: ' high ' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' }); + }); + + it('resets input state after adding label', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Add a label + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.change(valueInput, { target: { value: 'high' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + // Should be back to key input + expect( + screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + }); + + describe('Removing Labels', () => { + it('removes label when close button is clicked', () => { + const labels = { severity: 'high', environment: 'production' }; + renderLabelsInput({ labels }); + + const removeButtons = screen.getAllByTestId(CLOSE_ICON_TEST_ID); + fireEvent.click(removeButtons[0]); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ + environment: 'production', + }); + }); + + it('calls onLabelsChange with empty object when last label is removed', () => { + const labels = { severity: 'high' }; + renderLabelsInput({ labels }); + + const removeButton = screen.getByTestId('close-icon'); + fireEvent.click(removeButton); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({}); + }); + }); + + describe('Keyboard Interactions', () => { + it('cancels adding label on Escape key', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + + it('cancels adding label on Escape key in value input', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Cancel in value input + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.keyDown(valueInput, { key: 'Escape' }); + + expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + }); + + describe('Blur Behavior', () => { + it('closes input immediately when both key and value are empty', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.blur(input); + + // The input should close immediately when both key and value are empty + expect(screen.getByText(ADD_LABELS_TEXT)).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + + it('does not close input immediately when key has value', () => { + jest.useFakeTimers(); + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.blur(input); + + jest.advanceTimersByTime(200); + + expect( + screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).toBeInTheDocument(); + expect(screen.queryByText(ADD_LABELS_TEXT)).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); + }); + + describe('Input Change Handling', () => { + it('updates key input value correctly', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + fireEvent.change(input, { target: { value: 'severity' } }); + + expect(input).toHaveValue('severity'); + }); + + it('updates value input correctly', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Update value + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.change(valueInput, { target: { value: 'high' } }); + + expect(valueInput).toHaveValue('high'); + }); + }); + + describe('Edge Cases', () => { + it('handles multiple labels correctly', () => { + const labels = { + severity: 'high', + environment: 'production', + service: 'api-gateway', + }; + renderLabelsInput({ labels }); + + expect(screen.getByText(SEVERITY_HIGH_TEXT)).toBeInTheDocument(); + expect(screen.getByText(ENVIRONMENT_PRODUCTION_TEXT)).toBeInTheDocument(); + expect(screen.getByText('service: api-gateway')).toBeInTheDocument(); + expect(screen.getAllByTestId(CLOSE_ICON_TEST_ID)).toHaveLength(3); + }); + + it('handles empty string values', () => { + const labels = { severity: '' }; + renderLabelsInput({ labels }); + + expect(screen.getByText(/severity/)).toBeInTheDocument(); + }); + + it('handles special characters in labels', () => { + const labels = { 'service-name': 'api-gateway-v1' }; + renderLabelsInput({ labels }); + + expect(screen.getByText('service-name: api-gateway-v1')).toBeInTheDocument(); + }); + + it('maintains focus on input after adding label', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Add a label + fireEvent.change(input, { target: { value: 'severity' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + const valueInput = screen.getByPlaceholderText(ENTER_VALUE_PLACEHOLDER); + fireEvent.change(valueInput, { target: { value: 'high' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + // Should be focused on new key input + const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + expect(newInput).toHaveFocus(); + }); + }); + + describe('Key:Value Format Support', () => { + it('adds label when key:value format is entered and Enter is pressed', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key:value format + fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' }); + }); + + it('trims whitespace from key and value in key:value format', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key:value format with whitespace + fireEvent.change(input, { target: { value: ' severity : high ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ severity: 'high' }); + }); + + it('handles values with colons correctly', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key:value format where value contains colons + fireEvent.change(input, { + target: { value: 'url:https://example.com:8080' }, + }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).toHaveBeenCalledWith({ + url: 'https://example.com:8080', + }); + }); + + it('does not add label if key is empty in key:value format', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key:value format with empty key + fireEvent.change(input, { target: { value: ':high' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).not.toHaveBeenCalled(); + }); + + it('does not add label if value is empty in key:value format', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter key:value format with empty value + fireEvent.change(input, { target: { value: 'severity:' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).not.toHaveBeenCalled(); + }); + + it('does not add label if only colon is entered', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Enter only colon + fireEvent.change(input, { target: { value: ':' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnLabelsChange).not.toHaveBeenCalled(); + }); + + it('resets input state after adding label with key:value format', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Add label with key:value format + fireEvent.change(input, { target: { value: 'severity:high' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Should be back to key input for next label + expect( + screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(ENTER_VALUE_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); + + it('does not auto-save when typing key:value without pressing Enter', () => { + renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Type key:value format but don't press Enter + fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } }); + + // Should not have called onLabelsChange yet + expect(mockOnLabelsChange).not.toHaveBeenCalled(); + }); + + it('handles multiple key:value entries correctly', () => { + const { rerender } = renderLabelsInput(); + + fireEvent.click(screen.getByText(ADD_LABELS_TEXT)); + const input = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + + // Add first label + fireEvent.change(input, { target: { value: SEVERITY_HIGH_KEY_VALUE } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + // Simulate parent component updating labels + const firstLabels = { severity: 'high' }; + rerender( + , + ); + + // Add second label + const newInput = screen.getByPlaceholderText(ENTER_KEY_PLACEHOLDER); + fireEvent.change(newInput, { target: { value: 'environment:production' } }); + fireEvent.keyDown(newInput, { key: 'Enter' }); + + // Check that we made two calls and the last one includes both labels + expect(mockOnLabelsChange).toHaveBeenCalledTimes(2); + expect(mockOnLabelsChange).toHaveBeenNthCalledWith(1, { severity: 'high' }); + expect(mockOnLabelsChange).toHaveBeenNthCalledWith(2, { + severity: 'high', + environment: 'production', + }); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/index.ts b/frontend/src/container/CreateAlertV2/CreateAlertHeader/index.ts new file mode 100644 index 000000000000..658ab98b3cb1 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/index.ts @@ -0,0 +1,3 @@ +import CreateAlertHeader from './CreateAlertHeader'; + +export default CreateAlertHeader; diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss new file mode 100644 index 000000000000..c594cbebc226 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss @@ -0,0 +1,151 @@ +.alert-header { + background-color: var(--bg-ink-500); + font-family: inherit; + color: var(--text-vanilla-100); + + /* Top bar with diagonal stripes */ + &__tab-bar { + height: 32px; + display: flex; + align-items: center; + background: repeating-linear-gradient( + -45deg, + #0f0f0f, + #0f0f0f 10px, + #101010 10px, + #101010 20px + ); + padding-left: 0; + } + + /* Tab block visuals */ + &__tab { + display: flex; + align-items: center; + background-color: var(--bg-ink-500); + padding: 0 12px; + height: 32px; + font-size: 13px; + color: var(--text-vanilla-100); + margin-left: 12px; + margin-top: 12px; + } + + &__tab::before { + content: '•'; + margin-right: 6px; + font-size: 14px; + color: var(--bg-slate-100); + } + + &__content { + padding: 16px; + background: var(--bg-ink-500); + display: flex; + flex-direction: column; + gap: 8px; + } + + &__input.title { + font-size: 18px; + font-weight: 500; + background-color: transparent; + color: var(--text-vanilla-100); + } + + &__input:focus, + &__input:active { + border: none; + outline: none; + } + + &__input.description { + font-size: 14px; + background-color: transparent; + color: var(--text-vanilla-300); + } +} + +.labels-input { + display: flex; + flex-direction: column; + gap: 8px; + + &__add-button { + width: fit-content; + font-size: 13px; + color: #ccc; + border: 1px solid #333; + background-color: transparent; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + + &:hover { + border-color: #555; + color: #fff; + } + } + + &__existing-labels { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__label-pill { + display: inline-flex; + align-items: center; + gap: 6px; + background-color: #ad7f581a; + color: var(--bg-sienna-400); + padding: 4px 8px; + border-radius: 16px; + font-size: 12px; + border: 1px solid var(--bg-sienna-500); + font-family: 'Geist Mono'; + } + + &__remove-button { + background: none; + border: none; + color: var(--bg-sienna-400); + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + + &:hover { + color: var(--text-vanilla-100); + } + } + + &__input-container { + display: flex; + align-items: center; + background-color: transparent; + border: none; + } + + &__input { + flex: 1; + background-color: transparent; + border: none; + outline: none; + padding: 6px 8px; + color: #fff; + font-size: 13px; + + &::placeholder { + color: #888; + } + + &:focus, + &:active { + border: none; + outline: none; + } + } +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/types.ts b/frontend/src/container/CreateAlertV2/CreateAlertHeader/types.ts new file mode 100644 index 000000000000..e40ca9d88e5d --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/types.ts @@ -0,0 +1,13 @@ +import { Labels } from 'types/api/alerts/def'; + +export interface LabelsInputProps { + labels: Labels; + onLabelsChange: (labels: Labels) => void; + validateLabelsKey: (key: string) => string | null; +} + +export interface LabelInputState { + key: string; + value: string; + isKeyInput: boolean; +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss new file mode 100644 index 000000000000..23c38b075b8b --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss @@ -0,0 +1,17 @@ +$top-nav-background-1: #0f0f0f; +$top-nav-background-2: #101010; + +.create-alert-v2-container { + background-color: var(--bg-ink-500); +} + +.top-nav-container { + background: repeating-linear-gradient( + -45deg, + $top-nav-background-1, + $top-nav-background-1 10px, + $top-nav-background-2 10px, + $top-nav-background-2 20px + ); + margin-bottom: 0; +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx new file mode 100644 index 000000000000..589a18fc6ca1 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx @@ -0,0 +1,34 @@ +import './CreateAlertV2.styles.scss'; + +import { initialQueriesMap } from 'constants/queryBuilder'; +import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import AlertCondition from './AlertCondition'; +import { CreateAlertProvider } from './context'; +import CreateAlertHeader from './CreateAlertHeader'; +import QuerySection from './QuerySection'; + +function CreateAlertV2({ + initialQuery = initialQueriesMap.metrics, +}: { + initialQuery?: Query; +}): JSX.Element { + useShareBuilderUrl({ defaultValue: initialQuery }); + + return ( +
+ + + + + +
+ ); +} + +CreateAlertV2.defaultProps = { + initialQuery: initialQueriesMap.metrics, +}; + +export default CreateAlertV2; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss new file mode 100644 index 000000000000..90306dcac286 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.scss @@ -0,0 +1,51 @@ +.time-input-container { + display: flex; + align-items: center; + gap: 0; + + .time-input-field { + width: 40px; + height: 32px; + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-slate-400); + color: var(--bg-vanilla-100); + font-family: 'Space Mono', monospace; + font-size: 14px; + font-weight: 600; + text-align: center; + border-radius: 4px; + + &::placeholder { + color: var(--bg-vanilla-400); + font-family: 'Space Mono', monospace; + } + + &:hover { + border-color: var(--bg-vanilla-300); + } + + &:focus { + border-color: var(--bg-vanilla-300); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + outline: none; + } + + &:disabled { + background-color: var(--bg-ink-300); + color: var(--bg-vanilla-400); + cursor: not-allowed; + + &:hover { + border-color: var(--bg-slate-400); + } + } + } + + .time-input-separator { + color: var(--bg-vanilla-400); + font-size: 14px; + font-weight: 600; + margin: 0 4px; + user-select: none; + } +} diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx new file mode 100644 index 000000000000..a8609642df60 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx @@ -0,0 +1,195 @@ +import './TimeInput.scss'; + +import { Input } from 'antd'; +import React, { useEffect, useState } from 'react'; + +export interface TimeInputProps { + value?: string; // Format: "HH:MM:SS" + onChange?: (value: string) => void; + disabled?: boolean; + className?: string; +} + +function TimeInput({ + value = '00:00:00', + onChange, + disabled = false, + className = '', +}: TimeInputProps): JSX.Element { + const [hours, setHours] = useState('00'); + const [minutes, setMinutes] = useState('00'); + const [seconds, setSeconds] = useState('00'); + + // Parse initial value + useEffect(() => { + if (value) { + const timeParts = value.split(':'); + if (timeParts.length === 3) { + setHours(timeParts[0]); + setMinutes(timeParts[1]); + setSeconds(timeParts[2]); + } + } + }, [value]); + + const notifyChange = (h: string, m: string, s: string): void => { + const rawValue = `${h}:${m}:${s}`; + onChange?.(rawValue); + }; + + const notifyFormattedChange = (h: string, m: string, s: string): void => { + const formattedValue = `${h.padStart(2, '0')}:${m.padStart( + 2, + '0', + )}:${s.padStart(2, '0')}`; + onChange?.(formattedValue); + }; + + const handleHoursChange = (e: React.ChangeEvent): void => { + let newHours = e.target.value.replace(/\D/g, ''); + + if (newHours.length > 2) { + newHours = newHours.slice(0, 2); + } + + if (newHours && parseInt(newHours, 10) > 23) { + newHours = '23'; + } + setHours(newHours); + notifyChange(newHours, minutes, seconds); + }; + + const handleMinutesChange = (e: React.ChangeEvent): void => { + let newMinutes = e.target.value.replace(/\D/g, ''); + if (newMinutes.length > 2) { + newMinutes = newMinutes.slice(0, 2); + } + if (newMinutes && parseInt(newMinutes, 10) > 59) { + newMinutes = '59'; + } + setMinutes(newMinutes); + notifyChange(hours, newMinutes, seconds); + }; + + const handleSecondsChange = (e: React.ChangeEvent): void => { + let newSeconds = e.target.value.replace(/\D/g, ''); + if (newSeconds.length > 2) { + newSeconds = newSeconds.slice(0, 2); + } + if (newSeconds && parseInt(newSeconds, 10) > 59) { + newSeconds = '59'; + } + setSeconds(newSeconds); + notifyChange(hours, minutes, newSeconds); + }; + + const handleHoursBlur = (): void => { + const formattedHours = hours.padStart(2, '0'); + setHours(formattedHours); + notifyFormattedChange(formattedHours, minutes, seconds); + }; + + const handleMinutesBlur = (): void => { + const formattedMinutes = minutes.padStart(2, '0'); + setMinutes(formattedMinutes); + notifyFormattedChange(hours, formattedMinutes, seconds); + }; + + const handleSecondsBlur = (): void => { + const formattedSeconds = seconds.padStart(2, '0'); + setSeconds(formattedSeconds); + notifyFormattedChange(hours, minutes, formattedSeconds); + }; + + // Helper functions for field navigation + const getNextField = (current: string): string => { + switch (current) { + case 'hours': + return 'minutes'; + case 'minutes': + return 'seconds'; + default: + return 'hours'; + } + }; + + const getPrevField = (current: string): string => { + switch (current) { + case 'seconds': + return 'minutes'; + case 'minutes': + return 'hours'; + default: + return 'seconds'; + } + }; + + // Handle key navigation + const handleKeyDown = ( + e: React.KeyboardEvent, + currentField: 'hours' | 'minutes' | 'seconds', + ): void => { + if (e.key === 'ArrowRight' || e.key === 'Tab') { + e.preventDefault(); + const nextField = document.querySelector( + `[data-field="${getNextField(currentField)}"]`, + ) as HTMLInputElement; + nextField?.focus(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prevField = document.querySelector( + `[data-field="${getPrevField(currentField)}"]`, + ) as HTMLInputElement; + prevField?.focus(); + } + }; + + return ( +
+ handleKeyDown(e, 'hours')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> + : + handleKeyDown(e, 'minutes')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> + : + handleKeyDown(e, 'seconds')} + disabled={disabled} + maxLength={2} + className="time-input-field" + placeholder="00" + /> +
+ ); +} + +TimeInput.defaultProps = { + value: '00:00:00', + onChange: undefined, + disabled: false, + className: '', +}; + +export default TimeInput; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts new file mode 100644 index 000000000000..6ce0eced8a96 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/index.ts @@ -0,0 +1,3 @@ +import TimeInput from './TimeInput'; + +export default TimeInput; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx new file mode 100644 index 000000000000..9101647e5150 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/TimeInput.test.tsx @@ -0,0 +1,241 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import TimeInput from '../TimeInput/TimeInput'; + +describe('TimeInput', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render with default value', () => { + render(); + + expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds + }); + + it('should render with provided value', () => { + render(); + + expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours + expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes + expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds + }); + + it('should handle hours changes', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '12' } }); + + expect(mockOnChange).toHaveBeenCalledWith('12:00:00'); + }); + + it('should handle minutes changes', () => { + render(); + + const minutesInput = screen.getAllByDisplayValue('00')[1]; + fireEvent.change(minutesInput, { target: { value: '30' } }); + + expect(mockOnChange).toHaveBeenCalledWith('00:30:00'); + }); + + it('should handle seconds changes', () => { + render(); + + const secondsInput = screen.getAllByDisplayValue('00')[2]; + fireEvent.change(secondsInput, { target: { value: '45' } }); + + expect(mockOnChange).toHaveBeenCalledWith('00:00:45'); + }); + + it('should pad single digits with zeros on blur', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '5' } }); + fireEvent.blur(hoursInput); + + expect(mockOnChange).toHaveBeenCalledWith('05:00:00'); + }); + + it('should filter non-numeric characters', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '1a2b3c' } }); + + expect(mockOnChange).toHaveBeenCalledWith('12:00:00'); + }); + + it('should limit input to 2 characters', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '123456' } }); + + expect(hoursInput).toHaveValue('12'); + expect(mockOnChange).toHaveBeenCalledWith('12:00:00'); + }); + + it('should handle keyboard navigation with ArrowRight', async () => { + const user = userEvent.setup(); + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + const minutesInput = screen.getAllByDisplayValue('00')[1]; + + await user.click(hoursInput); + await user.keyboard('{ArrowRight}'); + + expect(minutesInput).toHaveFocus(); + }); + + it('should handle keyboard navigation with ArrowLeft', async () => { + const user = userEvent.setup(); + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + const minutesInput = screen.getAllByDisplayValue('00')[1]; + + await user.click(minutesInput); + await user.keyboard('{ArrowLeft}'); + + expect(hoursInput).toHaveFocus(); + }); + + it('should handle Tab navigation', async () => { + const user = userEvent.setup(); + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + const minutesInput = screen.getAllByDisplayValue('00')[1]; + + await user.click(hoursInput); + await user.keyboard('{Tab}'); + + expect(minutesInput).toHaveFocus(); + }); + + it('should disable inputs when disabled prop is true', () => { + render(); + + const inputs = screen.getAllByRole('textbox'); + inputs.forEach((input) => { + expect(input).toBeDisabled(); + }); + }); + + it('should update internal state when value prop changes', () => { + const { rerender } = render(); + + expect(screen.getByDisplayValue('01')).toBeInTheDocument(); + expect(screen.getByDisplayValue('02')).toBeInTheDocument(); + expect(screen.getByDisplayValue('03')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByDisplayValue('04')).toBeInTheDocument(); + expect(screen.getByDisplayValue('05')).toBeInTheDocument(); + expect(screen.getByDisplayValue('06')).toBeInTheDocument(); + }); + + it('should handle partial time values', () => { + render(); + + // Should fall back to default values for incomplete format + expect(screen.getAllByDisplayValue('00')).toHaveLength(3); + }); + + it('should cap hours at 23 when user enters value > 23', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '25' } }); + + expect(hoursInput).toHaveValue('23'); + expect(mockOnChange).toHaveBeenCalledWith('23:00:00'); + }); + + it('should cap hours at 23 when user enters value = 24', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '24' } }); + + expect(hoursInput).toHaveValue('23'); + expect(mockOnChange).toHaveBeenCalledWith('23:00:00'); + }); + + it('should allow hours value of 23', () => { + render(); + + const hoursInput = screen.getAllByDisplayValue('00')[0]; + fireEvent.change(hoursInput, { target: { value: '23' } }); + + expect(hoursInput).toHaveValue('23'); + expect(mockOnChange).toHaveBeenCalledWith('23:00:00'); + }); + + it('should cap minutes at 59 when user enters value > 59', () => { + render(); + + const minutesInput = screen.getAllByDisplayValue('00')[1]; + fireEvent.change(minutesInput, { target: { value: '65' } }); + + expect(minutesInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:59:00'); + }); + + it('should cap minutes at 59 when user enters value = 60', () => { + render(); + + const minutesInput = screen.getAllByDisplayValue('00')[1]; + fireEvent.change(minutesInput, { target: { value: '60' } }); + + expect(minutesInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:59:00'); + }); + + it('should allow minutes value of 59', () => { + render(); + + const minutesInput = screen.getAllByDisplayValue('00')[1]; + fireEvent.change(minutesInput, { target: { value: '59' } }); + + expect(minutesInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:59:00'); + }); + + it('should cap seconds at 59 when user enters value > 59', () => { + render(); + + const secondsInput = screen.getAllByDisplayValue('00')[2]; + fireEvent.change(secondsInput, { target: { value: '75' } }); + + expect(secondsInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:00:59'); + }); + + it('should cap seconds at 59 when user enters value = 60', () => { + render(); + + const secondsInput = screen.getAllByDisplayValue('00')[2]; + fireEvent.change(secondsInput, { target: { value: '60' } }); + + expect(secondsInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:00:59'); + }); + + it('should allow seconds value of 59', () => { + render(); + + const secondsInput = screen.getAllByDisplayValue('00')[2]; + fireEvent.change(secondsInput, { target: { value: '59' } }); + + expect(secondsInput).toHaveValue('59'); + expect(mockOnChange).toHaveBeenCalledWith('00:00:59'); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts new file mode 100644 index 000000000000..32eb21cd1c3d --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/utils.test.ts @@ -0,0 +1,656 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable import/first */ + +// Mock dayjs before importing any other modules +const MOCK_DATE_STRING = '2024-01-15T00:30:00Z'; +const MOCK_DATE_STRING_NON_LEAP_YEAR = '2023-01-15T00:30:00Z'; +const MOCK_DATE_STRING_SPANS_MONTHS = '2024-01-31T00:30:00Z'; +const FREQ_DAILY = 'FREQ=DAILY'; +const TEN_THIRTY_TIME = '10:30:00'; +const NINE_AM_TIME = '09:00:00'; +jest.mock('dayjs', () => { + const originalDayjs = jest.requireActual('dayjs'); + const mockDayjs = jest.fn((date?: string | Date) => { + if (date) { + return originalDayjs(date); + } + return originalDayjs(MOCK_DATE_STRING); + }); + Object.keys(originalDayjs).forEach((key) => { + ((mockDayjs as unknown) as Record)[key] = originalDayjs[key]; + }); + return mockDayjs; +}); + +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { EvaluationWindowState } from 'container/CreateAlertV2/context/types'; +import dayjs, { Dayjs } from 'dayjs'; +import { rrulestr } from 'rrule'; + +import { RollingWindowTimeframes } from '../types'; +import { + buildAlertScheduleFromCustomSchedule, + buildAlertScheduleFromRRule, + getCumulativeWindowTimeframeText, + getCustomRollingWindowTimeframeText, + getEvaluationWindowTypeText, + getRollingWindowTimeframeText, + getTimeframeText, + isValidRRule, +} from '../utils'; + +jest.mock('rrule', () => ({ + rrulestr: jest.fn(), +})); + +jest.mock('components/CustomTimePicker/timezoneUtils', () => ({ + generateTimezoneData: jest.fn().mockReturnValue([ + { name: 'UTC', value: 'UTC', offset: '+00:00' }, + { name: 'America/New_York', value: 'America/New_York', offset: '-05:00' }, + { name: 'Europe/London', value: 'Europe/London', offset: '+00:00' }, + ]), +})); + +const mockEvaluationWindowState: EvaluationWindowState = { + windowType: 'rolling', + timeframe: '5m0s', + startingAt: { + number: '0', + timezone: 'UTC', + time: '00:00:00', + unit: UniversalYAxisUnit.MINUTES, + }, +}; + +const formatDate = (date: Date): string => + dayjs(date).format('DD-MM-YYYY HH:mm:ss'); + +describe('utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getEvaluationWindowTypeText', () => { + it('should return correct text for rolling window', () => { + expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling'); + }); + + it('should return correct text for cumulative window', () => { + expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative'); + }); + + it('should default to empty string for unknown type', () => { + expect( + getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'), + ).toBe(''); + }); + }); + + describe('getCumulativeWindowTimeframeText', () => { + it('should return correct text for current hour', () => { + expect( + getCumulativeWindowTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'cumulative', + timeframe: 'currentHour', + }), + ).toBe('Current hour, starting at minute 0 (UTC)'); + }); + + it('should return correct text for current day', () => { + expect( + getCumulativeWindowTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'cumulative', + timeframe: 'currentDay', + }), + ).toBe('Current day, starting from 00:00:00 (UTC)'); + }); + + it('should return correct text for current month', () => { + expect( + getCumulativeWindowTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'cumulative', + timeframe: 'currentMonth', + }), + ).toBe('Current month, starting from day 0 at 00:00:00 (UTC)'); + }); + + it('should default to empty string for unknown timeframe', () => { + expect( + getCumulativeWindowTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'cumulative', + timeframe: 'unknown', + }), + ).toBe(''); + }); + }); + + describe('getRollingWindowTimeframeText', () => { + it('should return correct text for last 5 minutes', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES), + ).toBe('Last 5 minutes'); + }); + + it('should return correct text for last 10 minutes', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES), + ).toBe('Last 10 minutes'); + }); + + it('should return correct text for last 15 minutes', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES), + ).toBe('Last 15 minutes'); + }); + + it('should return correct text for last 30 minutes', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES), + ).toBe('Last 30 minutes'); + }); + + it('should return correct text for last 1 hour', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR), + ).toBe('Last 1 hour'); + }); + + it('should return correct text for last 2 hours', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS), + ).toBe('Last 2 hours'); + }); + + it('should return correct text for last 4 hours', () => { + expect( + getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS), + ).toBe('Last 4 hours'); + }); + + it('should default to Last 5 minutes for unknown timeframe', () => { + expect( + getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes), + ).toBe(''); + }); + }); + + describe('getCustomRollingWindowTimeframeText', () => { + it('should return correct text for custom rolling window', () => { + expect(getCustomRollingWindowTimeframeText(mockEvaluationWindowState)).toBe( + 'Last 0 Minutes', + ); + }); + }); + + describe('getTimeframeText', () => { + it('should call getCustomRollingWindowTimeframeText for custom rolling window', () => { + expect( + getTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'rolling', + timeframe: 'custom', + startingAt: { + ...mockEvaluationWindowState.startingAt, + number: '4', + }, + }), + ).toBe('Last 4 Minutes'); + }); + + it('should call getRollingWindowTimeframeText for rolling window', () => { + expect(getTimeframeText(mockEvaluationWindowState)).toBe('Last 5 minutes'); + }); + + it('should call getCumulativeWindowTimeframeText for cumulative window', () => { + expect( + getTimeframeText({ + ...mockEvaluationWindowState, + windowType: 'cumulative', + timeframe: 'currentDay', + }), + ).toBe('Current day, starting from 00:00:00 (UTC)'); + }); + }); + + describe('buildAlertScheduleFromRRule', () => { + const mockRRule = { + all: jest.fn((callback) => { + const dates = [ + new Date(MOCK_DATE_STRING), + new Date('2024-01-16T10:30:00Z'), + new Date('2024-01-17T10:30:00Z'), + ]; + dates.forEach((date, index) => callback(date, index)); + }), + }; + + beforeEach(() => { + (rrulestr as jest.Mock).mockReturnValue(mockRRule); + }); + + it('should return null for empty rrule string', () => { + const result = buildAlertScheduleFromRRule('', null, '10:30:00'); + expect(result).toBeNull(); + }); + + it('should build schedule from valid rrule string', () => { + const result = buildAlertScheduleFromRRule( + FREQ_DAILY, + null, + TEN_THIRTY_TIME, + ); + + expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY); + expect(result).toEqual([ + new Date(MOCK_DATE_STRING), + new Date('2024-01-16T10:30:00Z'), + new Date('2024-01-17T10:30:00Z'), + ]); + }); + + it('should handle rrule with DTSTART', () => { + const date = dayjs('2024-01-20'); + buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME); + + // When date is provided, DTSTART is automatically added to the rrule string + expect(rrulestr).toHaveBeenCalledWith( + expect.stringMatching(/DTSTART:20240120T\d{6}Z/), + ); + }); + + it('should handle rrule without DTSTART', () => { + // Test with no date provided - should use original rrule string + const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME); + + expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY); + expect(result).toHaveLength(3); + }); + + it('should handle escaped newlines', () => { + buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00'); + + expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1'); + }); + + it('should limit occurrences to maxOccurrences', () => { + const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2); + + expect(result).toHaveLength(2); + }); + + it('should return null on error', () => { + (rrulestr as jest.Mock).mockImplementation(() => { + throw new Error('Invalid rrule'); + }); + + const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00'); + expect(result).toBeNull(); + }); + }); + + describe('buildAlertScheduleFromCustomSchedule', () => { + it('should generate monthly occurrences', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['1', '15'], + '10:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 10:30:00', + '01-02-2024 10:30:00', + '15-02-2024 10:30:00', + '01-03-2024 10:30:00', + '15-03-2024 10:30:00', + ]); + }); + + it('should generate weekly occurrences', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'week', + ['monday', 'friday'], + '12:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 12:30:00', + '19-01-2024 12:30:00', + '22-01-2024 12:30:00', + '26-01-2024 12:30:00', + '29-01-2024 12:30:00', + ]); + }); + + it('should generate weekly occurrences including today if alert time is in the future', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'week', + ['monday', 'friday'], + '10:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today included (15-01-2024 00:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 10:30:00', + '19-01-2024 10:30:00', + '22-01-2024 10:30:00', + '26-01-2024 10:30:00', + '29-01-2024 10:30:00', + ]); + }); + + it('should generate weekly occurrences excluding today if alert time is in the past', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'week', + ['monday', 'friday'], + '00:00:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today excluded (15-01-2024 00:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '19-01-2024 00:00:00', + '22-01-2024 00:00:00', + '26-01-2024 00:00:00', + '29-01-2024 00:00:00', + '02-02-2024 00:00:00', + ]); + }); + + it('should generate weekly occurrences excluding today if alert time is in the present (right now)', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'week', + ['monday', 'friday'], + '00:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today excluded (15-01-2024 00:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '19-01-2024 00:30:00', + '22-01-2024 00:30:00', + '26-01-2024 00:30:00', + '29-01-2024 00:30:00', + '02-02-2024 00:30:00', + ]); + }); + + it('should generate monthly occurrences including today if alert time is in the future', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['15'], + '10:30:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today included (15-01-2024 10:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 10:30:00', + '15-02-2024 10:30:00', + '15-03-2024 10:30:00', + '15-04-2024 10:30:00', + '15-05-2024 10:30:00', + ]); + }); + + it('should generate monthly occurrences excluding today if alert time is in the past', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['15'], + '00:00:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today excluded (15-01-2024 10:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-02-2024 00:00:00', + '15-03-2024 00:00:00', + '15-04-2024 00:00:00', + '15-05-2024 00:00:00', + '15-06-2024 00:00:00', + ]); + }); + + it('should generate monthly occurrences excluding today if alert time is in the present (right now)', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['15'], + '00:30:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + // today excluded (15-01-2024 10:30:00) + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-02-2024 00:30:00', + '15-03-2024 00:30:00', + '15-04-2024 00:30:00', + '15-05-2024 00:30:00', + '15-06-2024 00:30:00', + ]); + }); + + it('should account for february 29th in a leap year', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['29'], + '10:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '29-01-2024 10:30:00', + '29-02-2024 10:30:00', + '29-03-2024 10:30:00', + '29-04-2024 10:30:00', + '29-05-2024 10:30:00', + ]); + }); + + it('should skip 31st on 30-day months', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['31'], + '10:30:00', + 5, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '31-01-2024 10:30:00', + '31-03-2024 10:30:00', + '31-05-2024 10:30:00', + '31-07-2024 10:30:00', + '31-08-2024 10:30:00', + ]); + }); + + it('should skip february 29th in a non-leap year', async () => { + jest.resetModules(); // clear previous mocks + + jest.doMock('dayjs', () => { + const originalDayjs = jest.requireActual('dayjs'); + const mockDayjs = (date?: string | Date): Dayjs => { + if (date) return originalDayjs(date); + return originalDayjs(MOCK_DATE_STRING_NON_LEAP_YEAR); + }; + Object.assign(mockDayjs, originalDayjs); + return mockDayjs; + }); + + const { buildAlertScheduleFromCustomSchedule } = await import('../utils'); + const { default: dayjs } = await import('dayjs'); + + const formatDate = (date: Date): string => + dayjs(date).format('DD-MM-YYYY HH:mm:ss'); + + const result = buildAlertScheduleFromCustomSchedule( + 'month', + ['29'], + '10:30:00', + 5, + ); + + expect(result?.map((res) => formatDate(res))).toEqual([ + '29-01-2023 10:30:00', + '29-03-2023 10:30:00', + '29-04-2023 10:30:00', + '29-05-2023 10:30:00', + '29-06-2023 10:30:00', + ]); + }); + + it('should generate daily occurrences', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'day', + [], + '10:40:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 10:40:00', + '16-01-2024 10:40:00', + '17-01-2024 10:40:00', + '18-01-2024 10:40:00', + '19-01-2024 10:40:00', + ]); + }); + + it('should generate daily occurrences excluding today if alert time is in the past', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'day', + [], + '00:00:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '16-01-2024 00:00:00', + '17-01-2024 00:00:00', + '18-01-2024 00:00:00', + '19-01-2024 00:00:00', + '20-01-2024 00:00:00', + ]); + }); + + it('should generate daily occurrences excluding today if alert time is in the present (right now)', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'day', + [], + '00:30:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '16-01-2024 00:30:00', + '17-01-2024 00:30:00', + '18-01-2024 00:30:00', + '19-01-2024 00:30:00', + '20-01-2024 00:30:00', + ]); + }); + + it('should generate daily occurrences including today if alert time is in the future', () => { + const result = buildAlertScheduleFromCustomSchedule( + 'day', + [], + '10:30:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '15-01-2024 10:30:00', + '16-01-2024 10:30:00', + '17-01-2024 10:30:00', + '18-01-2024 10:30:00', + '19-01-2024 10:30:00', + ]); + }); + + it('daily occurrences should span across months correctly', async () => { + jest.resetModules(); // clear previous mocks + + jest.doMock('dayjs', () => { + const originalDayjs = jest.requireActual('dayjs'); + const mockDayjs = (date?: string | Date): Dayjs => { + if (date) return originalDayjs(date); + return originalDayjs(MOCK_DATE_STRING_SPANS_MONTHS); + }; + Object.assign(mockDayjs, originalDayjs); + return mockDayjs; + }); + + const { buildAlertScheduleFromCustomSchedule } = await import('../utils'); + const { default: dayjs } = await import('dayjs'); + + const formatDate = (date: Date): string => + dayjs(date).format('DD-MM-YYYY HH:mm:ss'); + + const result = buildAlertScheduleFromCustomSchedule( + 'day', + [], + '10:30:00', + 5, + ); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result?.map((res) => formatDate(res))).toEqual([ + '31-01-2024 10:30:00', + '01-02-2024 10:30:00', + '02-02-2024 10:30:00', + '03-02-2024 10:30:00', + '04-02-2024 10:30:00', + ]); + }); + }); + + describe('isValidRRule', () => { + beforeEach(() => { + (rrulestr as jest.Mock).mockReturnValue({}); + }); + + it('should return true for valid rrule', () => { + expect(isValidRRule(FREQ_DAILY)).toBe(true); + expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY); + }); + + it('should handle escaped newlines', () => { + expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true); + expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1'); + }); + + it('should return false for invalid rrule', () => { + (rrulestr as jest.Mock).mockImplementation(() => { + throw new Error('Invalid rrule'); + }); + + expect(isValidRRule('INVALID')).toBe(false); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts new file mode 100644 index 000000000000..9e92d667a3ce --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts @@ -0,0 +1,61 @@ +import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils'; + +export const EVALUATION_WINDOW_TYPE = [ + { label: 'Rolling', value: 'rolling' }, + { label: 'Cumulative', value: 'cumulative' }, +]; + +export const EVALUATION_WINDOW_TIMEFRAME = { + rolling: [ + { label: 'Last 5 minutes', value: '5m0s' }, + { label: 'Last 10 minutes', value: '10m0s' }, + { label: 'Last 15 minutes', value: '15m0s' }, + { label: 'Last 30 minutes', value: '30m0s' }, + { label: 'Last 1 hour', value: '1h0m0s' }, + { label: 'Last 2 hours', value: '2h0m0s' }, + { label: 'Last 4 hours', value: '4h0m0s' }, + ], + cumulative: [ + { label: 'Current hour', value: 'currentHour' }, + { label: 'Current day', value: 'currentDay' }, + { label: 'Current month', value: 'currentMonth' }, + ], +}; + +export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [ + { label: 'WEEK', value: 'week' }, + { label: 'MONTH', value: 'month' }, +]; + +export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [ + { label: 'SUNDAY', value: 'sunday' }, + { label: 'MONDAY', value: 'monday' }, + { label: 'TUESDAY', value: 'tuesday' }, + { label: 'WEDNESDAY', value: 'wednesday' }, + { label: 'THURSDAY', value: 'thursday' }, + { label: 'FRIDAY', value: 'friday' }, + { label: 'SATURDAY', value: 'saturday' }, +]; + +export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from( + { length: 31 }, + (_, i) => { + const value = String(i + 1); + return { label: value, value }; + }, +); + +export const WEEKDAY_MAP: { [key: string]: number } = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +}; + +export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({ + label: `${timezone.name} (${timezone.offset})`, + value: timezone.value, +})); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts new file mode 100644 index 000000000000..490fa7e0c94b --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts @@ -0,0 +1,53 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { + EvaluationWindowAction, + EvaluationWindowState, +} from '../context/types'; + +export interface IAdvancedOptionItemProps { + title: string; + description: string; + input: JSX.Element; +} + +export enum RollingWindowTimeframes { + 'LAST_5_MINUTES' = '5m0s', + 'LAST_10_MINUTES' = '10m0s', + 'LAST_15_MINUTES' = '15m0s', + 'LAST_30_MINUTES' = '30m0s', + 'LAST_1_HOUR' = '1h0m0s', + 'LAST_2_HOURS' = '2h0m0s', + 'LAST_4_HOURS' = '4h0m0s', +} + +export enum CumulativeWindowTimeframes { + 'CURRENT_HOUR' = 'currentHour', + 'CURRENT_DAY' = 'currentDay', + 'CURRENT_MONTH' = 'currentMonth', +} + +export interface IEvaluationWindowPopoverProps { + evaluationWindow: EvaluationWindowState; + setEvaluationWindow: Dispatch; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export interface IEvaluationWindowDetailsProps { + evaluationWindow: EvaluationWindowState; + setEvaluationWindow: Dispatch; +} + +export interface IEvaluationCadenceDetailsProps { + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export interface TimeInputProps { + value?: string; // Format: "HH:MM:SS" + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx new file mode 100644 index 000000000000..4788c4aee157 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/utils.tsx @@ -0,0 +1,295 @@ +import * as Sentry from '@sentry/react'; +import dayjs, { Dayjs } from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import { rrulestr } from 'rrule'; + +import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../context/constants'; +import { EvaluationWindowState } from '../context/types'; +import { WEEKDAY_MAP } from './constants'; +import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types'; + +// Extend dayjs with timezone plugins +dayjs.extend(utc); +dayjs.extend(timezone); + +export const getEvaluationWindowTypeText = ( + windowType: 'rolling' | 'cumulative', +): string => { + switch (windowType) { + case 'rolling': + return 'Rolling'; + case 'cumulative': + return 'Cumulative'; + default: + return ''; + } +}; + +export const getCumulativeWindowTimeframeText = ( + evaluationWindow: EvaluationWindowState, +): string => { + switch (evaluationWindow.timeframe) { + case CumulativeWindowTimeframes.CURRENT_HOUR: + return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`; + case CumulativeWindowTimeframes.CURRENT_DAY: + return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`; + case CumulativeWindowTimeframes.CURRENT_MONTH: + return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`; + default: + return ''; + } +}; + +export const getRollingWindowTimeframeText = ( + timeframe: RollingWindowTimeframes, +): string => { + switch (timeframe) { + case RollingWindowTimeframes.LAST_5_MINUTES: + return 'Last 5 minutes'; + case RollingWindowTimeframes.LAST_10_MINUTES: + return 'Last 10 minutes'; + case RollingWindowTimeframes.LAST_15_MINUTES: + return 'Last 15 minutes'; + case RollingWindowTimeframes.LAST_30_MINUTES: + return 'Last 30 minutes'; + case RollingWindowTimeframes.LAST_1_HOUR: + return 'Last 1 hour'; + case RollingWindowTimeframes.LAST_2_HOURS: + return 'Last 2 hours'; + case RollingWindowTimeframes.LAST_4_HOURS: + return 'Last 4 hours'; + default: + return ''; + } +}; + +export const getCustomRollingWindowTimeframeText = ( + evaluationWindow: EvaluationWindowState, +): string => + `Last ${evaluationWindow.startingAt.number} ${ + ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find( + (option) => option.value === evaluationWindow.startingAt.unit, + )?.label + }`; + +export const getTimeframeText = ( + evaluationWindow: EvaluationWindowState, +): string => { + if (evaluationWindow.windowType === 'rolling') { + if (evaluationWindow.timeframe === 'custom') { + return getCustomRollingWindowTimeframeText(evaluationWindow); + } + return getRollingWindowTimeframeText( + evaluationWindow.timeframe as RollingWindowTimeframes, + ); + } + return getCumulativeWindowTimeframeText(evaluationWindow); +}; + +export function buildAlertScheduleFromRRule( + rruleString: string, + date: Dayjs | null, + startAt: string, + maxOccurrences = 10, +): Date[] | null { + try { + if (!rruleString) return null; + + // Handle literal \n in string + let finalRRuleString = rruleString.replace(/\\n/g, '\n'); + + if (date) { + const dt = dayjs(date); + if (!dt.isValid()) throw new Error('Invalid date provided'); + + const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number); + + const dtWithTime = dt + .set('hour', hours) + .set('minute', minutes) + .set('second', seconds) + .set('millisecond', 0); + + const dtStartStr = dtWithTime + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, 'Z'); + + if (!/DTSTART/i.test(finalRRuleString)) { + finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`; + } + } + + const rruleObj = rrulestr(finalRRuleString); + const occurrences: Date[] = []; + rruleObj.all((date, index) => { + if (index >= maxOccurrences) return false; + occurrences.push(date); + return true; + }); + + return occurrences; + } catch (error) { + return null; + } +} + +function generateMonthlyOccurrences( + targetDays: number[], + hours: number, + minutes: number, + seconds: number, + maxOccurrences: number, +): Date[] { + const occurrences: Date[] = []; + const currentMonth = dayjs().startOf('month'); + + const currentDate = dayjs(); + + const scanMonths = maxOccurrences + 12; + for (let monthOffset = 0; monthOffset < scanMonths; monthOffset++) { + const monthDate = currentMonth.add(monthOffset, 'month'); + targetDays.forEach((day) => { + if (occurrences.length >= maxOccurrences) return; + + const daysInMonth = monthDate.daysInMonth(); + if (day <= daysInMonth) { + const targetDate = monthDate + .date(day) + .hour(hours) + .minute(minutes) + .second(seconds); + if (targetDate.isAfter(currentDate)) { + occurrences.push(targetDate.toDate()); + } + } + }); + } + + return occurrences; +} + +function generateWeeklyOccurrences( + targetWeekdays: number[], + hours: number, + minutes: number, + seconds: number, + maxOccurrences: number, +): Date[] { + const occurrences: Date[] = []; + const currentWeek = dayjs().startOf('week'); + + const currentDate = dayjs(); + + for (let weekOffset = 0; weekOffset < maxOccurrences; weekOffset++) { + const weekDate = currentWeek.add(weekOffset, 'week'); + targetWeekdays.forEach((weekday) => { + if (occurrences.length >= maxOccurrences) return; + + const targetDate = weekDate + .day(weekday) + .hour(hours) + .minute(minutes) + .second(seconds); + if (targetDate.isAfter(currentDate)) { + occurrences.push(targetDate.toDate()); + } + }); + } + + return occurrences; +} + +export function generateDailyOccurrences( + hours: number, + minutes: number, + seconds: number, + maxOccurrences: number, +): Date[] { + const occurrences: Date[] = []; + const currentDate = dayjs(); + const currentTime = + currentDate.hour() * 3600 + currentDate.minute() * 60 + currentDate.second(); + const targetTime = hours * 3600 + minutes * 60 + seconds; + + // Start from today if target time is after current time, otherwise start from tomorrow + const startDayOffset = targetTime > currentTime ? 0 : 1; + + for ( + let dayOffset = startDayOffset; + dayOffset < startDayOffset + maxOccurrences; + dayOffset++ + ) { + const dayDate = currentDate.add(dayOffset, 'day'); + const targetDate = dayDate.hour(hours).minute(minutes).second(seconds); + occurrences.push(targetDate.toDate()); + } + + return occurrences; +} + +export function buildAlertScheduleFromCustomSchedule( + repeatEvery: string, + occurence: string[], + startAt: string, + maxOccurrences = 10, +): Date[] | null { + try { + const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number); + let occurrences: Date[] = []; + + if (repeatEvery === 'month') { + const targetDays = occurence + .map((day) => parseInt(day, 10)) + .filter((day) => !Number.isNaN(day)); + occurrences = generateMonthlyOccurrences( + targetDays, + hours, + minutes, + seconds, + maxOccurrences, + ); + } else if (repeatEvery === 'week') { + const targetWeekdays = occurence + .map((day) => WEEKDAY_MAP[day.toLowerCase()]) + .filter((day) => day !== undefined); + occurrences = generateWeeklyOccurrences( + targetWeekdays, + hours, + minutes, + seconds, + maxOccurrences, + ); + } else if (repeatEvery === 'day') { + occurrences = generateDailyOccurrences( + hours, + minutes, + seconds, + maxOccurrences, + ); + } + + occurrences.sort((a, b) => a.getTime() - b.getTime()); + return occurrences.slice(0, maxOccurrences); + } catch (error) { + Sentry.captureEvent({ + message: `Error building alert schedule from custom schedule: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + level: 'error', + }); + return null; + } +} + +export function isValidRRule(rruleString: string): boolean { + try { + // normalize escaped \n + const finalRRuleString = rruleString.replace(/\\n/g, '\n'); + rrulestr(finalRRuleString); // will throw if invalid + return true; + } catch { + return false; + } +} diff --git a/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/ChartPreview.tsx b/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/ChartPreview.tsx new file mode 100644 index 000000000000..97ca2980ef40 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/ChartPreview.tsx @@ -0,0 +1,80 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { useCreateAlertState } from 'container/CreateAlertV2/context'; +import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview'; +import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { AlertDef } from 'types/api/alerts/def'; +import { EQueryType } from 'types/common/dashboard'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +export interface ChartPreviewProps { + alertDef: AlertDef; +} + +function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element { + const { currentQuery, panelType, stagedQuery } = useQueryBuilder(); + const { thresholdState, alertState } = useCreateAlertState(); + const { selectedTime: globalSelectedInterval } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + const [, setQueryStatus] = useState(''); + + const yAxisUnit = alertState.yAxisUnit || ''; + + const renderQBChartPreview = (): JSX.Element => ( + + } + name="" + query={stagedQuery} + selectedInterval={globalSelectedInterval} + alertDef={alertDef} + yAxisUnit={yAxisUnit || ''} + graphType={panelType || PANEL_TYPES.TIME_SERIES} + setQueryStatus={setQueryStatus} + showSideLegend + additionalThresholds={thresholdState.thresholds} + /> + ); + + const renderPromAndChQueryChartPreview = (): JSX.Element => ( + + } + name="Chart Preview" + query={stagedQuery} + alertDef={alertDef} + selectedInterval={globalSelectedInterval} + yAxisUnit={yAxisUnit || ''} + graphType={panelType || PANEL_TYPES.TIME_SERIES} + setQueryStatus={setQueryStatus} + showSideLegend + additionalThresholds={thresholdState.thresholds} + /> + ); + + return ( +
+ {currentQuery.queryType === EQueryType.QUERY_BUILDER && + renderQBChartPreview()} + {currentQuery.queryType === EQueryType.PROM && + renderPromAndChQueryChartPreview()} + {currentQuery.queryType === EQueryType.CLICKHOUSE && + renderPromAndChQueryChartPreview()} +
+ ); +} + +export default ChartPreview; diff --git a/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/index.ts b/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/index.ts new file mode 100644 index 000000000000..8845923e0588 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/ChartPreview/index.ts @@ -0,0 +1,3 @@ +import ChartPreview from './ChartPreview'; + +export default ChartPreview; diff --git a/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx b/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx new file mode 100644 index 000000000000..ec6312923de1 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx @@ -0,0 +1,98 @@ +import './styles.scss'; + +import { Button } from 'antd'; +import classNames from 'classnames'; +import YAxisUnitSelector from 'components/YAxisUnitSelector'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import QuerySectionComponent from 'container/FormAlertRules/QuerySection'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; + +import { useCreateAlertState } from '../context'; +import Stepper from '../Stepper'; +import ChartPreview from './ChartPreview'; +import { buildAlertDefForChartPreview } from './utils'; + +function QuerySection(): JSX.Element { + const { currentQuery, handleRunQuery } = useQueryBuilder(); + const { + alertState, + setAlertState, + alertType, + setAlertType, + thresholdState, + } = useCreateAlertState(); + + const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState }); + + const tabs = [ + { + label: 'Metrics', + icon: , + value: AlertTypes.METRICS_BASED_ALERT, + }, + { + label: 'Logs', + icon: , + value: AlertTypes.LOGS_BASED_ALERT, + }, + { + label: 'Traces', + icon: , + value: AlertTypes.TRACES_BASED_ALERT, + }, + { + label: 'Exceptions', + icon: , + value: AlertTypes.EXCEPTIONS_BASED_ALERT, + }, + ]; + + return ( +
+ + + { + setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value }); + }} + /> +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ {}} + alertType={alertType} + runQuery={handleRunQuery} + alertDef={alertDef} + panelType={PANEL_TYPES.TIME_SERIES} + key={currentQuery.queryType} + ruleId="" + hideTitle + /> +
+ ); +} + +export default QuerySection; diff --git a/frontend/src/container/CreateAlertV2/QuerySection/__tests__/ChartPreview.test.tsx b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/ChartPreview.test.tsx new file mode 100644 index 000000000000..0689a6920ae3 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/ChartPreview.test.tsx @@ -0,0 +1,282 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/destructuring-assignment */ +import { render, screen } from '@testing-library/react'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, +} from 'container/CreateAlertV2/context/constants'; +import { buildInitialAlertDef } from 'container/CreateAlertV2/context/utils'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { EQueryType } from 'types/common/dashboard'; + +import { CreateAlertProvider } from '../../context'; +import ChartPreview from '../ChartPreview/ChartPreview'; + +const REQUESTS_PER_SEC = 'requests/sec'; +const CHART_PREVIEW_NAME = 'Chart Preview'; +const QUERY_TYPE_TEST_ID = 'query-type'; +const GRAPH_TYPE_TEST_ID = 'graph-type'; +const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component'; +const PLOT_QUERY_TYPE_TEST_ID = 'plot-query-type'; +const PLOT_PANEL_TYPE_TEST_ID = 'plot-panel-type'; + +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: jest.fn(), +})); +jest.mock( + 'container/FormAlertRules/ChartPreview', + () => + function MockChartPreviewComponent(props: any): JSX.Element { + return ( +
+
{props.headline}
+
{props.name}
+
{props.query?.queryType}
+
+ {props.selectedInterval?.startTime} +
+
{props.yAxisUnit}
+
{props.graphType}
+
+ ); + }, +); +jest.mock( + 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag', + () => + function MockPlotTag(props: any): JSX.Element { + return ( +
+
{props.queryType}
+
{props.panelType}
+
+ ); + }, +); +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); + +// Mock react-redux +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (): any => ({ + globalTime: { + selectedTime: { + startTime: 1713734400000, + endTime: 1713738000000, + }, + maxTime: 1713738000000, + minTime: 1713734400000, + }, + }), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const mockUseQueryBuilder = { + currentQuery: { + queryType: EQueryType.QUERY_BUILDER, + unit: REQUESTS_PER_SEC, + builder: { + queryData: [ + { + dataSource: 'metrics', + }, + ], + }, + }, + panelType: PANEL_TYPES.TIME_SERIES, + stagedQuery: { + queryType: EQueryType.QUERY_BUILDER, + unit: REQUESTS_PER_SEC, + }, +}; + +const mockAlertDef = buildInitialAlertDef(AlertTypes.METRICS_BASED_ALERT); + +jest.mock('../../context', () => ({ + ...jest.requireActual('../../context'), + useCreateAlertState: (): any => ({ + alertState: { + ...INITIAL_ALERT_STATE, + yAxisUnit: REQUESTS_PER_SEC, + }, + thresholdState: INITIAL_ALERT_THRESHOLD_STATE, + setAlertState: jest.fn(), + setThresholdState: jest.fn(), + }), +})); + +const renderChartPreview = (): ReturnType => + render( + + + + + + + + + , + ); + +describe('ChartPreview', () => { + const { useQueryBuilder } = jest.requireMock( + 'hooks/queryBuilder/useQueryBuilder', + ); + + beforeEach(() => { + jest.clearAllMocks(); + useQueryBuilder.mockReturnValue(mockUseQueryBuilder); + }); + + it('renders the component with correct container class', () => { + renderChartPreview(); + + const container = screen + .getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID) + .closest('.chart-preview-container'); + expect(container).toBeInTheDocument(); + }); + + it('renders QueryBuilder chart preview when query type is QUERY_BUILDER', () => { + renderChartPreview(); + + expect( + screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID), + ).toBeInTheDocument(); + expect(screen.getByTestId('plot-tag')).toBeInTheDocument(); + expect(screen.getByTestId(PLOT_QUERY_TYPE_TEST_ID)).toHaveTextContent( + EQueryType.QUERY_BUILDER, + ); + expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.TIME_SERIES, + ); + }); + + it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => { + renderChartPreview(); + + expect(screen.getByTestId('name')).toHaveTextContent(''); + }); + + it('renders QueryBuilder chart preview with correct props', () => { + renderChartPreview(); + + expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent( + EQueryType.QUERY_BUILDER, + ); + expect(screen.getByTestId('y-axis-unit')).toHaveTextContent(REQUESTS_PER_SEC); + expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.TIME_SERIES, + ); + expect(screen.getByTestId('name')).toHaveTextContent(''); + expect(screen.getByTestId('headline')).toBeInTheDocument(); + expect(screen.getByTestId('selected-interval')).toBeInTheDocument(); + }); + + it('renders PromQL chart preview when query type is PROM', () => { + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + currentQuery: { + ...mockUseQueryBuilder.currentQuery, + queryType: EQueryType.PROM, + }, + stagedQuery: { + queryType: EQueryType.PROM, + unit: REQUESTS_PER_SEC, + }, + }); + + renderChartPreview(); + + expect( + screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID), + ).toBeInTheDocument(); + expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME); + expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent( + EQueryType.PROM, + ); + }); + + it('renders ClickHouse chart preview when query type is CLICKHOUSE', () => { + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + currentQuery: { + ...mockUseQueryBuilder.currentQuery, + queryType: EQueryType.CLICKHOUSE, + }, + stagedQuery: { + queryType: EQueryType.CLICKHOUSE, + unit: REQUESTS_PER_SEC, + }, + }); + + renderChartPreview(); + + expect( + screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID), + ).toBeInTheDocument(); + expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME); + expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent( + EQueryType.CLICKHOUSE, + ); + }); + + it('uses default panel type when panelType is not provided', () => { + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + panelType: undefined, + }); + + renderChartPreview(); + + expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.TIME_SERIES, + ); + expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.TIME_SERIES, + ); + expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent( + EQueryType.QUERY_BUILDER, + ); + }); + + it('uses custom panel type when provided', () => { + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + panelType: PANEL_TYPES.BAR, + }); + + renderChartPreview(); + + expect(screen.getByTestId(PLOT_PANEL_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.BAR, + ); + expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent( + PANEL_TYPES.BAR, + ); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx new file mode 100644 index 000000000000..7f341c6956ca --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx @@ -0,0 +1,307 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryParams } from 'constants/query'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; + +import { CreateAlertProvider } from '../../context'; +import QuerySection from '../QuerySection'; + +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: jest.fn(), +})); +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (): any => ({ + globalTime: { + selectedTime: { + startTime: 1713734400000, + endTime: 1713738000000, + }, + maxTime: 1713738000000, + minTime: 1713734400000, + }, + }), +})); +jest.mock( + 'container/FormAlertRules/QuerySection', + () => + function MockQuerySectionComponent({ + queryCategory, + alertType, + panelType, + }: any): JSX.Element { + return ( +
+
{queryCategory}
+
{alertType}
+
{panelType}
+
+ ); + }, +); +jest.mock( + '../ChartPreview', + () => + function MockChartPreview(): JSX.Element { + return
Chart Preview
; + }, +); +jest.mock( + '../../Stepper', + () => + function MockStepper({ stepNumber, label }: any): JSX.Element { + return ( +
+
{stepNumber}
+
{label}
+
+ ); + }, +); + +const mockUseQueryBuilder = { + currentQuery: { + queryType: 'query_builder', + unit: 'requests/sec', + builder: { + queryData: [ + { + dataSource: 'metrics', + }, + ], + }, + }, + handleRunQuery: jest.fn(), + redirectWithQueryBuilderData: jest.fn(), +}; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); +const renderQuerySection = (): ReturnType => + render( + + + + + + + + + , + ); + +const METRICS_TEXT = 'Metrics'; +const QUERY_BUILDER_TEXT = 'query_builder'; +const LOGS_TEXT = 'Logs'; +const TRACES_TEXT = 'Traces'; +const ACTIVE_TAB_CLASS = 'active-tab'; + +describe('QuerySection', () => { + const { useQueryBuilder } = jest.requireMock( + 'hooks/queryBuilder/useQueryBuilder', + ); + + beforeEach(() => { + jest.clearAllMocks(); + useQueryBuilder.mockReturnValue(mockUseQueryBuilder); + }); + + it('renders the component with all required elements', () => { + renderQuerySection(); + + // Check if Stepper is rendered + expect(screen.getByTestId('stepper')).toBeInTheDocument(); + expect(screen.getByTestId('step-number')).toHaveTextContent('1'); + expect(screen.getByTestId('step-label')).toHaveTextContent( + 'Define the query you want to set an alert on', + ); + + // Check if ChartPreview is rendered + expect(screen.getByTestId('chart-preview')).toBeInTheDocument(); + + // Check if QuerySectionComponent is rendered + expect(screen.getByTestId('query-section-component')).toBeInTheDocument(); + expect(screen.getByTestId('query-category')).toHaveTextContent( + QUERY_BUILDER_TEXT, + ); + expect(screen.getByTestId('alert-type')).toHaveTextContent( + AlertTypes.METRICS_BASED_ALERT, + ); + expect(screen.getByTestId('panel-type')).toHaveTextContent('graph'); + }); + + it('renders all three alert type tabs', () => { + renderQuerySection(); + + // Check if all tabs are rendered + expect(screen.getByText(METRICS_TEXT)).toBeInTheDocument(); + expect(screen.getByText('Logs')).toBeInTheDocument(); + expect(screen.getByText('Traces')).toBeInTheDocument(); + + // Check if icons are rendered + expect(screen.getByTestId('metrics-view')).toBeInTheDocument(); + expect(screen.getByTestId('logs-view')).toBeInTheDocument(); + expect(screen.getByTestId('traces-view')).toBeInTheDocument(); + }); + + it('shows Metrics tab as active by default', () => { + renderQuerySection(); + + const metricsTab = screen.getByText(METRICS_TEXT).closest('button'); + expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS); + }); + + it('handles alert type change when clicking on different tabs', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + // Click on Logs tab + const logsTab = screen.getByText(LOGS_TEXT); + await user.click(logsTab); + + // Verify that redirectWithQueryBuilderData was called with correct data + expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith( + expect.any(Object), + { + [QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT, + }, + undefined, + true, + ); + + // Click on Traces tab + const tracesTab = screen.getByText(TRACES_TEXT); + await user.click(tracesTab); + + // Verify that redirectWithQueryBuilderData was called with correct data + expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith( + expect.any(Object), + { + [QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT, + }, + undefined, + true, + ); + }); + + it('updates active tab when alert type changes', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + // Initially Metrics should be active + const metricsTab = screen.getByText(METRICS_TEXT).closest('button'); + expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS); + + // Click on Logs tab + const logsTab = screen.getByText(LOGS_TEXT); + await user.click(logsTab); + + // Logs should now be active + const logsButton = logsTab.closest('button'); + expect(logsButton).toHaveClass(ACTIVE_TAB_CLASS); + expect(metricsTab).not.toHaveClass(ACTIVE_TAB_CLASS); + }); + + it('passes correct props to QuerySectionComponent', () => { + renderQuerySection(); + + // Check if the component receives the correct props + expect(screen.getByTestId('query-category')).toHaveTextContent( + QUERY_BUILDER_TEXT, + ); + expect(screen.getByTestId('alert-type')).toHaveTextContent( + AlertTypes.METRICS_BASED_ALERT, + ); + expect(screen.getByTestId('panel-type')).toHaveTextContent('graph'); + }); + + it('has correct CSS classes for tab styling', () => { + renderQuerySection(); + + const tabs = screen.getAllByRole('button'); + + tabs.forEach((tab) => { + expect(tab).toHaveClass('list-view-tab'); + expect(tab).toHaveClass('explorer-view-option'); + }); + }); + + it('renders with correct container structure', () => { + renderQuerySection(); + + const container = screen.getByText(METRICS_TEXT).closest('.query-section'); + expect(container).toBeInTheDocument(); + + const tabsContainer = screen + .getByText(METRICS_TEXT) + .closest('.query-section-tabs'); + expect(tabsContainer).toBeInTheDocument(); + + const actionsContainer = screen + .getByText(METRICS_TEXT) + .closest('.query-section-query-actions'); + expect(actionsContainer).toBeInTheDocument(); + }); + + it('handles multiple rapid tab clicks correctly', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + const logsTab = screen.getByText('Logs'); + const tracesTab = screen.getByText('Traces'); + + // Rapidly click on different tabs + await user.click(logsTab); + await user.click(tracesTab); + await user.click(logsTab); + + // Should have called redirectWithQueryBuilderData 3 times + expect( + mockUseQueryBuilder.redirectWithQueryBuilderData, + ).toHaveBeenCalledTimes(3); + }); + + it('maintains tab state correctly after interactions', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + // Click on Logs tab + const logsTab = screen.getByText('Logs'); + await user.click(logsTab); + + // Verify Logs is active + const logsButton = logsTab.closest('button'); + expect(logsButton).toHaveClass(ACTIVE_TAB_CLASS); + + // Click back to Metrics + const metricsTab = screen.getByText(METRICS_TEXT); + await user.click(metricsTab); + + // Verify Metrics is active again + const metricsButton = metricsTab.closest('button'); + expect(metricsButton).toHaveClass(ACTIVE_TAB_CLASS); + expect(logsButton).not.toHaveClass(ACTIVE_TAB_CLASS); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/QuerySection/index.ts b/frontend/src/container/CreateAlertV2/QuerySection/index.ts new file mode 100644 index 000000000000..b3ff502ac5fd --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/index.ts @@ -0,0 +1,3 @@ +import QuerySection from './QuerySection'; + +export default QuerySection; diff --git a/frontend/src/container/CreateAlertV2/QuerySection/styles.scss b/frontend/src/container/CreateAlertV2/QuerySection/styles.scss new file mode 100644 index 000000000000..25efc0855755 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/styles.scss @@ -0,0 +1,101 @@ +.query-section { + margin: 0 16px; + .query-section-tabs { + display: flex; + align-items: center; + margin-left: 12px; + margin-top: 24px; + + .query-section-query-actions { + display: flex; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + flex-direction: row; + border-bottom: none; + margin-bottom: -1px; + + .prom-ql-icon { + height: 14px; + width: 14px; + } + + .explorer-view-option { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + border: none; + padding: 9px; + box-shadow: none; + border-radius: 0px; + border-left: 0.5px solid var(--bg-slate-400); + border-bottom: 0.5px solid var(--bg-slate-400); + width: 120px; + height: 36px; + + gap: 8px; + + &.active-tab { + background-color: var(--bg-ink-500); + border-bottom: none; + + &:hover { + background-color: var(--bg-ink-500) !important; + } + } + + &:disabled { + background-color: var(--bg-ink-300); + opacity: 0.6; + } + + &:first-child { + border-left: 1px solid transparent; + } + + &:hover { + background-color: transparent !important; + border-left: 1px solid transparent !important; + color: var(--bg-vanilla-100); + } + } + } + + .frequency-chart-view-controller { + display: flex; + align-items: center; + padding-left: 8px; + gap: 8px; + } + } + + .y-axis-unit-selector-component { + margin-top: 16px; + + .ant-select { + .ant-select-selector { + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + } + } + } + + .chart-preview-container { + margin-right: 4px; + .alert-chart-container { + .ant-card { + border: 1px solid var(--bg-slate-500); + .ant-card-body { + background-color: var(--bg-ink-500); + } + } + } + } + + .alert-query-section-container { + margin: 0; + background-color: var(--bg-ink-500); + border: 1px solid var(--bg-slate-400); + } +} diff --git a/frontend/src/container/CreateAlertV2/QuerySection/utils.tsx b/frontend/src/container/CreateAlertV2/QuerySection/utils.tsx new file mode 100644 index 000000000000..29dfed08382b --- /dev/null +++ b/frontend/src/container/CreateAlertV2/QuerySection/utils.tsx @@ -0,0 +1,28 @@ +import { AlertDetectionTypes } from 'container/FormAlertRules'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { AlertDef } from 'types/api/alerts/def'; + +import { AlertThresholdState } from '../context/types'; +import { buildInitialAlertDef } from '../context/utils'; + +export function buildAlertDefForChartPreview({ + alertType, + thresholdState, +}: { + alertType: AlertTypes; + thresholdState: AlertThresholdState; +}): AlertDef { + const initialAlertDef = buildInitialAlertDef(alertType); + + return { + ...initialAlertDef, + ruleType: + alertType === AlertTypes.ANOMALY_BASED_ALERT + ? AlertDetectionTypes.ANOMALY_DETECTION_ALERT + : AlertDetectionTypes.THRESHOLD_ALERT, + condition: { + ...initialAlertDef.condition, + targetUnit: thresholdState.thresholds?.[0].unit, + }, + }; +} diff --git a/frontend/src/container/CreateAlertV2/Stepper/index.tsx b/frontend/src/container/CreateAlertV2/Stepper/index.tsx new file mode 100644 index 000000000000..8988389a6109 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/Stepper/index.tsx @@ -0,0 +1,18 @@ +import './styles.scss'; + +interface StepperProps { + stepNumber: number; + label: string; +} + +function Stepper({ stepNumber, label }: StepperProps): JSX.Element { + return ( +
+
{stepNumber}
+
{label}
+
+
+ ); +} + +export default Stepper; diff --git a/frontend/src/container/CreateAlertV2/Stepper/styles.scss b/frontend/src/container/CreateAlertV2/Stepper/styles.scss new file mode 100644 index 000000000000..ed0ee65a2881 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/Stepper/styles.scss @@ -0,0 +1,44 @@ +.stepper-container { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; + padding: 16px 0; + border-radius: 8px; +} + +.step-number { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--bg-robin-400); + color: var(--text-slate-400); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.step-label { + font-size: 12px; + line-height: 20px; + font-weight: 500; + color: #e5e7eb; + text-transform: uppercase; + letter-spacing: 0.1em; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + flex-shrink: 0; +} + +.dotted-line { + flex: 1; + height: 8px; + background-image: radial-gradient(circle, #4a4a4a 1px, transparent 1px); + background-size: 8px 8px; + background-repeat: repeat-x; + background-position: center; + margin-left: 8px; +} diff --git a/frontend/src/container/CreateAlertV2/context/constants.ts b/frontend/src/container/CreateAlertV2/context/constants.ts new file mode 100644 index 000000000000..44788e174c75 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/context/constants.ts @@ -0,0 +1,172 @@ +import { Color } from '@signozhq/design-tokens'; +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants'; +import dayjs from 'dayjs'; +import getRandomColor from 'lib/getRandomColor'; +import { v4 } from 'uuid'; + +import { + AdvancedOptionsState, + AlertState, + AlertThresholdMatchType, + AlertThresholdOperator, + AlertThresholdState, + Algorithm, + EvaluationWindowState, + Seasonality, + Threshold, + TimeDuration, +} from './types'; + +export const INITIAL_ALERT_STATE: AlertState = { + name: '', + description: '', + labels: {}, + yAxisUnit: undefined, +}; + +export const INITIAL_CRITICAL_THRESHOLD: Threshold = { + id: v4(), + label: 'CRITICAL', + thresholdValue: 0, + recoveryThresholdValue: 0, + unit: '', + channels: [], + color: Color.BG_SAKURA_500, +}; + +export const INITIAL_WARNING_THRESHOLD: Threshold = { + id: v4(), + label: 'WARNING', + thresholdValue: 0, + recoveryThresholdValue: 0, + unit: '', + channels: [], + color: Color.BG_AMBER_500, +}; + +export const INITIAL_INFO_THRESHOLD: Threshold = { + id: v4(), + label: 'INFO', + thresholdValue: 0, + recoveryThresholdValue: 0, + unit: '', + channels: [], + color: Color.BG_ROBIN_500, +}; + +export const INITIAL_RANDOM_THRESHOLD: Threshold = { + id: v4(), + label: '', + thresholdValue: 0, + recoveryThresholdValue: 0, + unit: '', + channels: [], + color: getRandomColor(), +}; + +export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = { + selectedQuery: 'A', + operator: AlertThresholdOperator.IS_ABOVE, + matchType: AlertThresholdMatchType.AT_LEAST_ONCE, + evaluationWindow: TimeDuration.FIVE_MINUTES, + algorithm: Algorithm.STANDARD, + seasonality: Seasonality.HOURLY, + thresholds: [INITIAL_CRITICAL_THRESHOLD], +}; + +export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = { + sendNotificationIfDataIsMissing: { + toleranceLimit: 15, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + enforceMinimumDatapoints: { + minimumDatapoints: 0, + }, + delayEvaluation: { + delay: 5, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + evaluationCadence: { + mode: 'default', + default: { + value: 1, + timeUnit: UniversalYAxisUnit.MINUTES, + }, + custom: { + repeatEvery: 'week', + startAt: '00:00:00', + occurence: [], + timezone: TIMEZONE_DATA[0].value, + }, + rrule: { + date: dayjs(), + startAt: '00:00:00', + rrule: '', + }, + }, +}; + +export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = { + windowType: 'rolling', + timeframe: '5m0s', + startingAt: { + time: '00:00:00', + number: '1', + timezone: TIMEZONE_DATA[0].value, + unit: UniversalYAxisUnit.MINUTES, + }, +}; + +export const THRESHOLD_OPERATOR_OPTIONS = [ + { value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' }, + { value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' }, + { value: AlertThresholdOperator.IS_EQUAL_TO, label: 'IS EQUAL TO' }, + { value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'IS NOT EQUAL TO' }, +]; + +export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [ + { value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' }, + { value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' }, + { value: AlertThresholdOperator.ABOVE_BELOW, label: 'ABOVE/BELOW' }, +]; + +export const THRESHOLD_MATCH_TYPE_OPTIONS = [ + { value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' }, + { value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' }, + { value: AlertThresholdMatchType.ON_AVERAGE, label: 'ON AVERAGE' }, + { value: AlertThresholdMatchType.IN_TOTAL, label: 'IN TOTAL' }, + { value: AlertThresholdMatchType.LAST, label: 'LAST' }, +]; + +export const ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS = [ + { value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' }, + { value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' }, +]; + +export const ANOMALY_TIME_DURATION_OPTIONS = [ + { value: TimeDuration.FIVE_MINUTES, label: '5 minutes' }, + { value: TimeDuration.TEN_MINUTES, label: '10 minutes' }, + { value: TimeDuration.FIFTEEN_MINUTES, label: '15 minutes' }, + { value: TimeDuration.ONE_HOUR, label: '1 hour' }, + { value: TimeDuration.THREE_HOURS, label: '3 hours' }, + { value: TimeDuration.FOUR_HOURS, label: '4 hours' }, + { value: TimeDuration.TWENTY_FOUR_HOURS, label: '24 hours' }, +]; + +export const ANOMALY_ALGORITHM_OPTIONS = [ + { value: Algorithm.STANDARD, label: 'Standard' }, +]; + +export const ANOMALY_SEASONALITY_OPTIONS = [ + { value: Seasonality.HOURLY, label: 'Hourly' }, + { value: Seasonality.DAILY, label: 'Daily' }, + { value: Seasonality.WEEKLY, label: 'Weekly' }, +]; + +export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [ + { value: UniversalYAxisUnit.SECONDS, label: 'Seconds' }, + { value: UniversalYAxisUnit.MINUTES, label: 'Minutes' }, + { value: UniversalYAxisUnit.HOURS, label: 'Hours' }, + { value: UniversalYAxisUnit.DAYS, label: 'Days' }, +]; diff --git a/frontend/src/container/CreateAlertV2/context/index.tsx b/frontend/src/container/CreateAlertV2/context/index.tsx new file mode 100644 index 000000000000..14221ba25130 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/context/index.tsx @@ -0,0 +1,131 @@ +import { QueryParams } from 'constants/query'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; + +import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, +} from './constants'; +import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types'; +import { + advancedOptionsReducer, + alertCreationReducer, + alertThresholdReducer, + buildInitialAlertDef, + evaluationWindowReducer, + getInitialAlertTypeFromURL, +} from './utils'; + +const CreateAlertContext = createContext(null); + +// Hook exposing context state for CreateAlert +export const useCreateAlertState = (): ICreateAlertContextProps => { + const context = useContext(CreateAlertContext); + if (!context) { + throw new Error( + 'useCreateAlertState must be used within CreateAlertProvider', + ); + } + return context; +}; + +export function CreateAlertProvider( + props: ICreateAlertProviderProps, +): JSX.Element { + const { children } = props; + + const [alertState, setAlertState] = useReducer( + alertCreationReducer, + INITIAL_ALERT_STATE, + ); + + const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + + const [alertType, setAlertType] = useState(() => + getInitialAlertTypeFromURL(queryParams, currentQuery), + ); + + const handleAlertTypeChange = useCallback( + (value: AlertTypes): void => { + const queryToRedirect = buildInitialAlertDef(value); + const currentQueryToRedirect = mapQueryDataFromApi( + queryToRedirect.condition.compositeQuery, + ); + redirectWithQueryBuilderData( + currentQueryToRedirect, + { + [QueryParams.alertType]: value, + }, + undefined, + true, + ); + setAlertType(value); + }, + [redirectWithQueryBuilderData], + ); + + const [thresholdState, setThresholdState] = useReducer( + alertThresholdReducer, + INITIAL_ALERT_THRESHOLD_STATE, + ); + + const [evaluationWindow, setEvaluationWindow] = useReducer( + evaluationWindowReducer, + INITIAL_EVALUATION_WINDOW_STATE, + ); + + const [advancedOptions, setAdvancedOptions] = useReducer( + advancedOptionsReducer, + INITIAL_ADVANCED_OPTIONS_STATE, + ); + + useEffect(() => { + setThresholdState({ + type: 'RESET', + }); + }, [alertType]); + + const contextValue: ICreateAlertContextProps = useMemo( + () => ({ + alertState, + setAlertState, + alertType, + setAlertType: handleAlertTypeChange, + thresholdState, + setThresholdState, + evaluationWindow, + setEvaluationWindow, + advancedOptions, + setAdvancedOptions, + }), + [ + alertState, + alertType, + handleAlertTypeChange, + thresholdState, + evaluationWindow, + advancedOptions, + ], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/container/CreateAlertV2/context/types.ts b/frontend/src/container/CreateAlertV2/context/types.ts new file mode 100644 index 000000000000..a76909054cc4 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/context/types.ts @@ -0,0 +1,192 @@ +import { Dayjs } from 'dayjs'; +import { Dispatch } from 'react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { Labels } from 'types/api/alerts/def'; + +export interface ICreateAlertContextProps { + alertState: AlertState; + setAlertState: Dispatch; + alertType: AlertTypes; + setAlertType: Dispatch; + thresholdState: AlertThresholdState; + setThresholdState: Dispatch; + advancedOptions: AdvancedOptionsState; + setAdvancedOptions: Dispatch; + evaluationWindow: EvaluationWindowState; + setEvaluationWindow: Dispatch; +} + +export interface ICreateAlertProviderProps { + children: React.ReactNode; +} + +export enum AlertCreationStep { + ALERT_DEFINITION = 0, + ALERT_CONDITION = 1, + EVALUATION_SETTINGS = 2, + NOTIFICATION_SETTINGS = 3, +} + +export interface AlertState { + name: string; + description: string; + labels: Labels; + yAxisUnit: string | undefined; +} + +export type CreateAlertAction = + | { type: 'SET_ALERT_NAME'; payload: string } + | { type: 'SET_ALERT_DESCRIPTION'; payload: string } + | { type: 'SET_ALERT_LABELS'; payload: Labels } + | { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }; + +export interface Threshold { + id: string; + label: string; + thresholdValue: number; + recoveryThresholdValue: number; + unit: string; + channels: string[]; + color: string; +} + +export enum AlertThresholdOperator { + IS_ABOVE = '1', + IS_BELOW = '2', + IS_EQUAL_TO = '3', + IS_NOT_EQUAL_TO = '4', + ABOVE_BELOW = '7', +} + +export enum AlertThresholdMatchType { + AT_LEAST_ONCE = '1', + ALL_THE_TIME = '2', + ON_AVERAGE = '3', + IN_TOTAL = '4', + LAST = '5', +} + +export interface AlertThresholdState { + selectedQuery: string; + operator: AlertThresholdOperator; + matchType: AlertThresholdMatchType; + evaluationWindow: string; + algorithm: string; + seasonality: string; + thresholds: Threshold[]; +} + +export enum TimeDuration { + ONE_MINUTE = '1m0s', + FIVE_MINUTES = '5m0s', + TEN_MINUTES = '10m0s', + FIFTEEN_MINUTES = '15m0s', + ONE_HOUR = '1h0m0s', + THREE_HOURS = '3h0m0s', + FOUR_HOURS = '4h0m0s', + TWENTY_FOUR_HOURS = '24h0m0s', +} + +export enum Algorithm { + STANDARD = 'standard', +} + +export enum Seasonality { + HOURLY = 'hourly', + DAILY = 'daily', + WEEKLY = 'weekly', +} + +export type AlertThresholdAction = + | { type: 'SET_SELECTED_QUERY'; payload: string } + | { type: 'SET_OPERATOR'; payload: AlertThresholdOperator } + | { type: 'SET_MATCH_TYPE'; payload: AlertThresholdMatchType } + | { type: 'SET_EVALUATION_WINDOW'; payload: string } + | { type: 'SET_ALGORITHM'; payload: string } + | { type: 'SET_SEASONALITY'; payload: string } + | { type: 'SET_THRESHOLDS'; payload: Threshold[] } + | { type: 'RESET' }; + +export interface AdvancedOptionsState { + sendNotificationIfDataIsMissing: { + toleranceLimit: number; + timeUnit: string; + }; + enforceMinimumDatapoints: { + minimumDatapoints: number; + }; + delayEvaluation: { + delay: number; + timeUnit: string; + }; + evaluationCadence: { + mode: EvaluationCadenceMode; + default: { + value: number; + timeUnit: string; + }; + custom: { + repeatEvery: string; + startAt: string; + occurence: string[]; + timezone: string; + }; + rrule: { + date: Dayjs | null; + startAt: string; + rrule: string; + }; + }; +} + +export type AdvancedOptionsAction = + | { + type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING'; + payload: { toleranceLimit: number; timeUnit: string }; + } + | { + type: 'SET_ENFORCE_MINIMUM_DATAPOINTS'; + payload: { minimumDatapoints: number }; + } + | { + type: 'SET_DELAY_EVALUATION'; + payload: { delay: number; timeUnit: string }; + } + | { + type: 'SET_EVALUATION_CADENCE'; + payload: { + default: { value: number; timeUnit: string }; + custom: { + repeatEvery: string; + startAt: string; + timezone: string; + occurence: string[]; + }; + rrule: { date: Dayjs | null; startAt: string; rrule: string }; + }; + } + | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'RESET' }; + +export interface EvaluationWindowState { + windowType: 'rolling' | 'cumulative'; + timeframe: string; + startingAt: { + time: string; + number: string; + timezone: string; + unit: string; + }; +} + +export type EvaluationWindowAction = + | { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' } + | { type: 'SET_TIMEFRAME'; payload: string } + | { + type: 'SET_STARTING_AT'; + payload: { time: string; number: string; timezone: string; unit: string }; + } + | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } + | { type: 'RESET' }; + +export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule'; diff --git a/frontend/src/container/CreateAlertV2/context/utils.tsx b/frontend/src/container/CreateAlertV2/context/utils.tsx new file mode 100644 index 000000000000..aa295262c012 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/context/utils.tsx @@ -0,0 +1,174 @@ +import { QueryParams } from 'constants/query'; +import { + alertDefaults, + anamolyAlertDefaults, + exceptionAlertDefaults, + logAlertDefaults, + traceAlertDefaults, +} from 'container/CreateAlertRule/defaults'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { AlertDef } from 'types/api/alerts/def'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, +} from './constants'; +import { + AdvancedOptionsAction, + AdvancedOptionsState, + AlertState, + AlertThresholdAction, + AlertThresholdState, + CreateAlertAction, + EvaluationWindowAction, + EvaluationWindowState, +} from './types'; + +export const alertCreationReducer = ( + state: AlertState, + action: CreateAlertAction, +): AlertState => { + switch (action.type) { + case 'SET_ALERT_NAME': + return { + ...state, + name: action.payload, + }; + case 'SET_ALERT_DESCRIPTION': + return { + ...state, + description: action.payload, + }; + case 'SET_ALERT_LABELS': + return { + ...state, + labels: action.payload, + }; + case 'SET_Y_AXIS_UNIT': + return { + ...state, + yAxisUnit: action.payload, + }; + default: + return state; + } +}; + +export function getInitialAlertType(currentQuery: Query): AlertTypes { + const dataSource = + currentQuery.builder.queryData[0].dataSource || DataSource.METRICS; + switch (dataSource) { + case DataSource.METRICS: + return AlertTypes.METRICS_BASED_ALERT; + case DataSource.LOGS: + return AlertTypes.LOGS_BASED_ALERT; + case DataSource.TRACES: + return AlertTypes.TRACES_BASED_ALERT; + default: + return AlertTypes.METRICS_BASED_ALERT; + } +} + +export function buildInitialAlertDef(alertType: AlertTypes): AlertDef { + switch (alertType) { + case AlertTypes.LOGS_BASED_ALERT: + return logAlertDefaults; + case AlertTypes.TRACES_BASED_ALERT: + return traceAlertDefaults; + case AlertTypes.EXCEPTIONS_BASED_ALERT: + return exceptionAlertDefaults; + case AlertTypes.ANOMALY_BASED_ALERT: + return anamolyAlertDefaults; + case AlertTypes.METRICS_BASED_ALERT: + return alertDefaults; + default: + return alertDefaults; + } +} + +export function getInitialAlertTypeFromURL( + urlSearchParams: URLSearchParams, + currentQuery: Query, +): AlertTypes { + const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType); + return alertTypeFromURL + ? (alertTypeFromURL as AlertTypes) + : getInitialAlertType(currentQuery); +} + +export const alertThresholdReducer = ( + state: AlertThresholdState, + action: AlertThresholdAction, +): AlertThresholdState => { + switch (action.type) { + case 'SET_SELECTED_QUERY': + return { ...state, selectedQuery: action.payload }; + case 'SET_OPERATOR': + return { ...state, operator: action.payload }; + case 'SET_MATCH_TYPE': + return { ...state, matchType: action.payload }; + case 'SET_THRESHOLDS': + return { ...state, thresholds: action.payload }; + case 'RESET': + return INITIAL_ALERT_THRESHOLD_STATE; + default: + return state; + } +}; + +export const advancedOptionsReducer = ( + state: AdvancedOptionsState, + action: AdvancedOptionsAction, +): AdvancedOptionsState => { + switch (action.type) { + case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING': + return { ...state, sendNotificationIfDataIsMissing: action.payload }; + case 'SET_ENFORCE_MINIMUM_DATAPOINTS': + return { ...state, enforceMinimumDatapoints: action.payload }; + case 'SET_DELAY_EVALUATION': + return { ...state, delayEvaluation: action.payload }; + case 'SET_EVALUATION_CADENCE': + return { + ...state, + evaluationCadence: { ...state.evaluationCadence, ...action.payload }, + }; + case 'SET_EVALUATION_CADENCE_MODE': + return { + ...state, + evaluationCadence: { ...state.evaluationCadence, mode: action.payload }, + }; + case 'RESET': + return INITIAL_ADVANCED_OPTIONS_STATE; + default: + return state; + } +}; + +export const evaluationWindowReducer = ( + state: EvaluationWindowState, + action: EvaluationWindowAction, +): EvaluationWindowState => { + switch (action.type) { + case 'SET_WINDOW_TYPE': + return { + ...state, + windowType: action.payload, + startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt, + timeframe: + action.payload === 'rolling' + ? INITIAL_EVALUATION_WINDOW_STATE.timeframe + : 'currentHour', + }; + case 'SET_TIMEFRAME': + return { ...state, timeframe: action.payload }; + case 'SET_STARTING_AT': + return { ...state, startingAt: action.payload }; + case 'RESET': + return INITIAL_EVALUATION_WINDOW_STATE; + default: + return state; + } +}; diff --git a/frontend/src/container/CreateAlertV2/index.ts b/frontend/src/container/CreateAlertV2/index.ts new file mode 100644 index 000000000000..a0ce7e7814e0 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/index.ts @@ -0,0 +1,3 @@ +import CreateAlertV2 from './CreateAlertV2'; + +export default CreateAlertV2; diff --git a/frontend/src/container/CreateAlertV2/utils.tsx b/frontend/src/container/CreateAlertV2/utils.tsx new file mode 100644 index 000000000000..9451a2fc55f7 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/utils.tsx @@ -0,0 +1,3 @@ +// UI side feature flag +export const showNewCreateAlertsPage = (): boolean => + localStorage.getItem('showNewCreateAlertsPage') === 'true'; diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx index c6a4a7be6e50..068103de1d22 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -8,6 +8,8 @@ import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView'; +import { INITIAL_CRITICAL_THRESHOLD } from 'container/CreateAlertV2/context/constants'; +import { Threshold } from 'container/CreateAlertV2/context/types'; import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils'; import GridPanelSwitch from 'container/GridPanelSwitch'; import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util'; @@ -51,7 +53,7 @@ import { getTimeRange } from 'utils/getTimeRange'; import { AlertDetectionTypes } from '..'; import { ChartContainer } from './styles'; -import { getThresholdLabel } from './utils'; +import { getThresholds } from './utils'; export interface ChartPreviewProps { name: string; @@ -65,6 +67,8 @@ export interface ChartPreviewProps { allowSelectedIntervalForStepGen?: boolean; yAxisUnit: string; setQueryStatus?: (status: string) => void; + showSideLegend?: boolean; + additionalThresholds?: Threshold[]; } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -80,10 +84,27 @@ function ChartPreview({ alertDef, yAxisUnit, setQueryStatus, + showSideLegend = false, + additionalThresholds, }: ChartPreviewProps): JSX.Element | null { const { t } = useTranslation('alerts'); const dispatch = useDispatch(); - const threshold = alertDef?.condition.target || 0; + const thresholds: Threshold[] = useMemo( + () => + additionalThresholds || [ + { + ...INITIAL_CRITICAL_THRESHOLD, + thresholdValue: alertDef?.condition.target || 0, + unit: alertDef?.condition.targetUnit || '', + }, + ], + [ + additionalThresholds, + alertDef?.condition.target, + alertDef?.condition.targetUnit, + ], + ); + const [minTimeScale, setMinTimeScale] = useState(); const [maxTimeScale, setMaxTimeScale] = useState(); const [graphVisibility, setGraphVisibility] = useState([]); @@ -236,6 +257,18 @@ function ChartPreview({ const { timezone } = useTimezone(); + const legendPosition = useMemo(() => { + if (!showSideLegend) { + return LegendPosition.BOTTOM; + } + const numberOfSeries = + queryResponse?.data?.payload?.data?.result?.length || 0; + if (numberOfSeries <= 1) { + return LegendPosition.BOTTOM; + } + return LegendPosition.RIGHT; + }, [queryResponse?.data?.payload?.data?.result?.length, showSideLegend]); + const options = useMemo( () => getUPlotChartOptions({ @@ -250,24 +283,7 @@ function ChartPreview({ maxTimeScale, isDarkMode, onDragSelect, - thresholds: [ - { - index: '0', // no impact - keyIndex: 0, - moveThreshold: (): void => {}, - selectedGraph: PANEL_TYPES.TIME_SERIES, // no impact - thresholdValue: threshold, - thresholdLabel: `${t( - 'preview_chart_threshold_label', - )} (y=${getThresholdLabel( - optionName, - threshold, - alertDef?.condition.targetUnit, - yAxisUnit, - )})`, - thresholdUnit: alertDef?.condition.targetUnit, - }, - ], + thresholds: getThresholds(thresholds, t, optionName, yAxisUnit), softMax: null, softMin: null, panelType: graphType, @@ -279,7 +295,7 @@ function ChartPreview({ graphsVisibilityStates: graphVisibility, setGraphsVisibilityStates: setGraphVisibility, enhancedLegend: true, - legendPosition: LegendPosition.BOTTOM, + legendPosition, }), [ yAxisUnit, @@ -289,15 +305,15 @@ function ChartPreview({ maxTimeScale, isDarkMode, onDragSelect, - threshold, + thresholds, t, optionName, - alertDef?.condition.targetUnit, graphType, timezone.value, currentQuery, query, graphVisibility, + legendPosition, ], ); @@ -370,6 +386,8 @@ ChartPreview.defaultProps = { allowSelectedIntervalForStepGen: false, alertDef: undefined, setQueryStatus: (): void => {}, + showSideLegend: false, + additionalThresholds: undefined, }; export default ChartPreview; diff --git a/frontend/src/container/FormAlertRules/ChartPreview/utils.ts b/frontend/src/container/FormAlertRules/ChartPreview/utils.ts index 1fc2a2e24758..d79ebf98d28a 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/utils.ts +++ b/frontend/src/container/FormAlertRules/ChartPreview/utils.ts @@ -1,3 +1,7 @@ +import { Color } from '@signozhq/design-tokens'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { Threshold } from 'container/CreateAlertV2/context/types'; +import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { BooleanFormats, DataFormats, @@ -6,6 +10,7 @@ import { ThroughputFormats, TimeFormats, } from 'container/NewWidget/RightContainer/types'; +import { TFunction } from 'i18next'; import { dataFormatConfig, @@ -83,3 +88,57 @@ interface IUnit { sourceUnit?: string; targetUnit?: string; } + +export const getThresholds = ( + thresholds: Threshold[], + t: TFunction, + optionName: string, + yAxisUnit: string, +): ThresholdProps[] => { + const thresholdsToReturn = new Array(); + + thresholds.forEach((threshold, index) => { + // Push main threshold + const mainThreshold = { + index: index.toString(), + keyIndex: index, + moveThreshold: (): void => {}, + selectedGraph: PANEL_TYPES.TIME_SERIES, + thresholdValue: threshold.thresholdValue, + thresholdLabel: + threshold.label || + `${t('preview_chart_threshold_label')} (y=${getThresholdLabel( + optionName, + threshold.thresholdValue, + threshold.unit, + yAxisUnit, + )})`, + thresholdUnit: threshold.unit, + thresholdColor: threshold.color || Color.TEXT_SAKURA_500, + }; + thresholdsToReturn.push(mainThreshold); + + // Push recovery threshold + if (threshold.recoveryThresholdValue) { + const recoveryThreshold = { + index: (thresholds.length + index).toString(), + keyIndex: thresholds.length + index, + moveThreshold: (): void => {}, + selectedGraph: PANEL_TYPES.TIME_SERIES, // no impact + thresholdValue: threshold.recoveryThresholdValue, + thresholdLabel: threshold.label + ? `${threshold.label} (Recovery)` + : `${t('preview_chart_threshold_label')} (y=${getThresholdLabel( + optionName, + threshold.thresholdValue, + threshold.unit, + yAxisUnit, + )})`, + thresholdUnit: threshold.unit, + thresholdColor: threshold.color || Color.TEXT_SAKURA_500, + }; + thresholdsToReturn.push(recoveryThreshold); + } + }); + return thresholdsToReturn; +}; diff --git a/frontend/src/container/FormAlertRules/QuerySection.tsx b/frontend/src/container/FormAlertRules/QuerySection.tsx index 5009c0121ae5..1238a8501551 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.tsx +++ b/frontend/src/container/FormAlertRules/QuerySection.tsx @@ -31,6 +31,7 @@ function QuerySection({ alertDef, panelType, ruleId, + hideTitle, }: QuerySectionProps): JSX.Element { // init namespace for translations const { t } = useTranslation('alerts'); @@ -218,7 +219,9 @@ function QuerySection({ return ( <> - {t('alert_form_step2', { step: step2Label })} + {!hideTitle && ( + {t('alert_form_step2', { step: step2Label })} + )}
{renderTabs(alertType)}
{renderQuerySection(currentTab)} @@ -235,6 +238,11 @@ interface QuerySectionProps { alertDef: AlertDef; panelType: PANEL_TYPES; ruleId: string; + hideTitle?: boolean; } +QuerySection.defaultProps = { + hideTitle: false, +}; + export default QuerySection; diff --git a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx index 3de8d2fdf9e0..463a3b0a2191 100644 --- a/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/WidgetGraphComponent.test.tsx @@ -30,20 +30,6 @@ jest.mock('hooks/useSafeNavigate', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - // Mock data const mockProps: WidgetGraphComponentProps = { widget: { @@ -53,7 +39,6 @@ const mockProps: WidgetGraphComponentProps = { description: '', fillSpans: false, id: '17f905f6-d355-46bd-a78e-cbc87e6f58cc', - isStacked: false, mergeAllActiveQueries: false, nullZeroValues: 'zero', opacity: '1', diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx index 270b2a8cd660..fb55a67df77b 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsList.test.tsx @@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({ const queryClient = new QueryClient(); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: (): any => ({ diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx index 8736b1740d87..a385850093dd 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/HostsListTable.test.tsx @@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react'; import HostsListTable from '../HostsListTable'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container'; describe('HostsListTable', () => { diff --git a/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx b/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx index a38681f37256..c06b7483c688 100644 --- a/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx +++ b/frontend/src/container/InfraMonitoringHosts/__tests__/utilts.test.tsx @@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils'; const PROGRESS_BAR_CLASS = '.progress-bar'; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - describe('InfraMonitoringHosts utils', () => { describe('formatDataForTable', () => { it('should format host data correctly', () => { diff --git a/frontend/src/container/InfraMonitoringK8s/Clusters/utils.tsx b/frontend/src/container/InfraMonitoringK8s/Clusters/utils.tsx index 7dfc0e23d7f9..8be53cb38f0f 100644 --- a/frontend/src/container/InfraMonitoringK8s/Clusters/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Clusters/utils.tsx @@ -31,7 +31,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Mem Usage', + label: 'Mem Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -105,7 +105,7 @@ const columnsConfig = [ align: 'left', }, { - title:
Memory Utilization (bytes)
, + title:
Memory Utilization (WSS)
, dataIndex: 'memory', key: 'memory', width: 80, @@ -113,7 +113,7 @@ const columnsConfig = [ align: 'left', }, { - title:
Memory Allocatable (bytes)
, + title:
Memory Allocatable
, dataIndex: 'memory_allocatable', key: 'memory_allocatable', width: 80, diff --git a/frontend/src/container/InfraMonitoringK8s/DaemonSets/utils.tsx b/frontend/src/container/InfraMonitoringK8s/DaemonSets/utils.tsx index 5a3be3a741da..c9487f4e03dc 100644 --- a/frontend/src/container/InfraMonitoringK8s/DaemonSets/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/DaemonSets/utils.tsx @@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Mem Usage', + label: 'Mem Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -211,10 +211,10 @@ const columnsConfig = [ className: `column ${columnProgressBarClassName}`, }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, ellipsis: true, sorter: true, align: 'left', diff --git a/frontend/src/container/InfraMonitoringK8s/Deployments/utils.tsx b/frontend/src/container/InfraMonitoringK8s/Deployments/utils.tsx index b64845eb9c81..572a32d98586 100644 --- a/frontend/src/container/InfraMonitoringK8s/Deployments/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Deployments/utils.tsx @@ -203,10 +203,10 @@ const columnsConfig = [ align: 'left', }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, sorter: true, align: 'left', }, diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx index 0c3bc5793dab..453b8c7b8b57 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/__tests__/EntityLogs.test.tsx @@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({ return queryData; }; -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock( 'components/OverlayScrollbar/OverlayScrollbar', () => diff --git a/frontend/src/container/InfraMonitoringK8s/Jobs/utils.tsx b/frontend/src/container/InfraMonitoringK8s/Jobs/utils.tsx index b580969e57e0..d01d77f18559 100644 --- a/frontend/src/container/InfraMonitoringK8s/Jobs/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Jobs/utils.tsx @@ -84,7 +84,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Mem Usage', + label: 'Mem Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -238,10 +238,10 @@ const columnsConfig = [ className: `column ${columnProgressBarClassName}`, }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, ellipsis: true, sorter: true, align: 'left', diff --git a/frontend/src/container/InfraMonitoringK8s/Namespaces/utils.tsx b/frontend/src/container/InfraMonitoringK8s/Namespaces/utils.tsx index cf9e5cfb31d6..3d66b5af9d36 100644 --- a/frontend/src/container/InfraMonitoringK8s/Namespaces/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Namespaces/utils.tsx @@ -99,10 +99,10 @@ const columnsConfig = [ align: 'left', }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, sorter: true, align: 'left', }, diff --git a/frontend/src/container/InfraMonitoringK8s/Nodes/utils.tsx b/frontend/src/container/InfraMonitoringK8s/Nodes/utils.tsx index 96d475bd35e4..56bd7c62baba 100644 --- a/frontend/src/container/InfraMonitoringK8s/Nodes/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Nodes/utils.tsx @@ -37,7 +37,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Memory Usage (bytes)', + label: 'Memory Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -121,7 +121,7 @@ const columnsConfig = [ align: 'left', }, { - title:
Memory Usage (bytes)
, + title:
Memory Usage (WSS)
, dataIndex: 'memory', key: 'memory', width: 80, @@ -129,7 +129,7 @@ const columnsConfig = [ align: 'left', }, { - title:
Memory Alloc (bytes)
, + title:
Memory Allocatable
, dataIndex: 'memory_allocatable', key: 'memory_allocatable', width: 80, diff --git a/frontend/src/container/InfraMonitoringK8s/StatefulSets/utils.tsx b/frontend/src/container/InfraMonitoringK8s/StatefulSets/utils.tsx index 0e393e99bf4a..ec4b9025b78c 100644 --- a/frontend/src/container/InfraMonitoringK8s/StatefulSets/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/StatefulSets/utils.tsx @@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Mem Usage', + label: 'Mem Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -211,10 +211,10 @@ const columnsConfig = [ className: `column ${columnProgressBarClassName}`, }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, ellipsis: true, sorter: true, align: 'left', diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx index 7b32c9226ada..0a650a2dd043 100644 --- a/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx @@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks'; setupCommonMocks(); -import { fireEvent, render, screen } from '@testing-library/react'; import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails'; -import { QueryClient, QueryClientProvider } from 'react-query'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import store from 'store'; - -const queryClient = new QueryClient(); +import { fireEvent, render, screen } from 'tests/test-utils'; describe('JobDetails', () => { const mockJob = { @@ -24,13 +18,7 @@ describe('JobDetails', () => { it('should render modal with relevant metadata', () => { render( - - - - - - - , + , ); const jobNameElements = screen.getAllByText('test-job'); @@ -44,13 +32,7 @@ describe('JobDetails', () => { it('should render modal with 4 tabs', () => { render( - - - - - - - , + , ); const metricsTab = screen.getByText('Metrics'); @@ -68,13 +50,7 @@ describe('JobDetails', () => { it('default tab should be metrics', () => { render( - - - - - - - , + , ); const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); @@ -83,13 +59,7 @@ describe('JobDetails', () => { it('should switch to events tab when events tab is clicked', () => { render( - - - - - - - , + , ); const eventsTab = screen.getByRole('radio', { name: 'Events' }); @@ -100,13 +70,7 @@ describe('JobDetails', () => { it('should close modal when close button is clicked', () => { render( - - - - - - - , + , ); const closeButton = screen.getByRole('button', { name: 'Close' }); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts index 41bd0782ace1..244e569248bd 100644 --- a/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts @@ -56,19 +56,6 @@ const setupCommonMocks = (): void => { useNavigationType: (): any => 'PUSH', })); - jest.mock('hooks/useUrlQuery', () => ({ - __esModule: true, - default: jest.fn(() => ({ - set: jest.fn(), - delete: jest.fn(), - get: jest.fn(), - has: jest.fn(), - entries: jest.fn(() => []), - append: jest.fn(), - toString: jest.fn(() => ''), - })), - })); - jest.mock('lib/getMinMax', () => ({ __esModule: true, default: jest.fn().mockImplementation(() => ({ diff --git a/frontend/src/container/InfraMonitoringK8s/utils.tsx b/frontend/src/container/InfraMonitoringK8s/utils.tsx index 976bda2ffa74..e95aff2354d2 100644 --- a/frontend/src/container/InfraMonitoringK8s/utils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/utils.tsx @@ -69,7 +69,7 @@ export const defaultAddedColumns: IEntityColumn[] = [ canRemove: false, }, { - label: 'Mem Usage', + label: 'Mem Usage (WSS)', value: 'memory', id: 'memory', canRemove: false, @@ -207,10 +207,10 @@ const columnsConfig = [ className: `column ${columnProgressBarClassName}`, }, { - title:
Mem Usage
, + title:
Mem Usage (WSS)
, dataIndex: 'memory', key: 'memory', - width: 80, + width: 120, ellipsis: true, sorter: true, align: 'left', diff --git a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx index 144c5b5b502c..d189edfcb728 100644 --- a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx +++ b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx @@ -240,7 +240,6 @@ function LiveLogsContainer(): JSX.Element { {showFormatMenuItems && ( { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('container/OptionsMenu', () => ({ useOptionsMenu: (): any => ({ options: { diff --git a/frontend/src/container/Login/index.tsx b/frontend/src/container/Login/index.tsx index 3ee204619869..33e8f0edac79 100644 --- a/frontend/src/container/Login/index.tsx +++ b/frontend/src/container/Login/index.tsx @@ -16,7 +16,7 @@ import { useAppContext } from 'providers/App/App'; import { useEffect, useState } from 'react'; import { useQuery } from 'react-query'; import APIError from 'types/api/error'; -import { PayloadProps as PrecheckResultType } from 'types/api/user/loginPrecheck'; +import { Signup as PrecheckResultType } from 'types/api/user/loginPrecheck'; import { FormContainer, Label, ParentContainer } from './styles'; diff --git a/frontend/src/container/LogsExplorerChart/utils.ts b/frontend/src/container/LogsExplorerChart/utils.ts index 40253750da26..dc51d005d2f1 100644 --- a/frontend/src/container/LogsExplorerChart/utils.ts +++ b/frontend/src/container/LogsExplorerChart/utils.ts @@ -2,12 +2,80 @@ import { Color } from '@signozhq/design-tokens'; import { themeColors } from 'constants/theme'; import { colors } from 'lib/getRandomColor'; +const SEVERITY_VARIANT_COLORS: Record = { + TRACE: Color.BG_FOREST_600, + Trace: Color.BG_FOREST_500, + trace: Color.BG_FOREST_400, + trc: Color.BG_FOREST_300, + Trc: Color.BG_FOREST_200, + + DEBUG: Color.BG_AQUA_600, + Debug: Color.BG_AQUA_500, + debug: Color.BG_AQUA_400, + dbg: Color.BG_AQUA_300, + Dbg: Color.BG_AQUA_200, + + INFO: Color.BG_ROBIN_600, + Info: Color.BG_ROBIN_500, + info: Color.BG_ROBIN_400, + Information: Color.BG_ROBIN_300, + information: Color.BG_ROBIN_200, + + WARN: Color.BG_AMBER_600, + Warn: Color.BG_AMBER_500, + warn: Color.BG_AMBER_400, + warning: Color.BG_AMBER_300, + Warning: Color.BG_AMBER_200, + wrn: Color.BG_AMBER_300, + Wrn: Color.BG_AMBER_200, + + ERROR: Color.BG_CHERRY_600, + Error: Color.BG_CHERRY_500, + error: Color.BG_CHERRY_400, + err: Color.BG_CHERRY_300, + Err: Color.BG_CHERRY_200, + ERR: Color.BG_CHERRY_600, + fail: Color.BG_CHERRY_400, + Fail: Color.BG_CHERRY_300, + FAIL: Color.BG_CHERRY_600, + + FATAL: Color.BG_SAKURA_600, + Fatal: Color.BG_SAKURA_500, + fatal: Color.BG_SAKURA_400, + critical: Color.BG_SAKURA_300, + Critical: Color.BG_SAKURA_200, + CRITICAL: Color.BG_SAKURA_600, + crit: Color.BG_SAKURA_300, + Crit: Color.BG_SAKURA_200, + CRIT: Color.BG_SAKURA_600, + panic: Color.BG_SAKURA_400, + Panic: Color.BG_SAKURA_300, + PANIC: Color.BG_SAKURA_600, +}; + +// Simple function to get severity color for any component +export function getSeverityColor(severityText: string): string { + const variantColor = SEVERITY_VARIANT_COLORS[severityText.trim()]; + if (variantColor) { + return variantColor; + } + + return Color.BG_ROBIN_500; // Default fallback +} + export function getColorsForSeverityLabels( label: string, index: number, ): string { + // Check if we have a direct mapping for this severity variant + const variantColor = SEVERITY_VARIANT_COLORS[label.trim()]; + if (variantColor) { + return variantColor; + } + const lowerCaseLabel = label.toLowerCase(); + // Fallback to old format for backward compatibility if (lowerCaseLabel.includes(`{severity_text="trace"}`)) { return Color.BG_FOREST_400; } diff --git a/frontend/src/container/LogsExplorerList/ColumnView/ColumnView.tsx b/frontend/src/container/LogsExplorerList/ColumnView/ColumnView.tsx index 052095277e26..9e65d648fa4c 100644 --- a/frontend/src/container/LogsExplorerList/ColumnView/ColumnView.tsx +++ b/frontend/src/container/LogsExplorerList/ColumnView/ColumnView.tsx @@ -4,7 +4,6 @@ import { ColumnDef, DataTable, Row } from '@signozhq/table'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES } from 'components/LogDetail/constants'; import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator'; -import { getLogIndicatorTypeForTable } from 'components/Logs/LogStateIndicator/utils'; import { useTableView } from 'components/Logs/TableView/useTableView'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -169,10 +168,14 @@ function ColumnView({ getValue: () => string | JSX.Element; }): string | JSX.Element => { if (field.key === 'state-indicator') { - const type = getLogIndicatorTypeForTable(row.original); const fontSize = options.fontSize as FontSize; - return ; + return ( + + ); } const isTimestamp = field.key === 'timestamp'; diff --git a/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx b/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx index d9fee0694bcd..f30864174c22 100644 --- a/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx +++ b/frontend/src/container/LogsExplorerList/__tests__/LogsExplorerList.test.tsx @@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({ }), })); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({ __esModule: true, useGetExplorerQueryRange: jest.fn(), diff --git a/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx b/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx index c366046307a2..452345a9ea7e 100644 --- a/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx +++ b/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx @@ -1,15 +1,13 @@ -import { Button, Switch, Typography } from 'antd'; +import { Switch, Typography } from 'antd'; import { WsDataEvent } from 'api/common/getQueryStats'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; +import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu'; import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu'; import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy'; import { LOCALSTORAGE } from 'constants/localStorage'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import Download from 'container/DownloadV2/DownloadV2'; import { useOptionsMenu } from 'container/OptionsMenu'; -import useClickOutside from 'hooks/useClickOutside'; -import { ArrowUp10, Minus, Sliders } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { ArrowUp10, Minus } from 'lucide-react'; import { DataSource, StringOperators } from 'types/common/queryBuilder'; import QueryStatus from './QueryStatus'; @@ -22,11 +20,12 @@ function LogsActionsContainer({ handleToggleFrequencyChart, orderBy, setOrderBy, - flattenLogData, isFetching, isLoading, isError, isSuccess, + minTime, + maxTime, }: { listQuery: any; selectedPanelType: PANEL_TYPES; @@ -34,16 +33,14 @@ function LogsActionsContainer({ handleToggleFrequencyChart: () => void; orderBy: string; setOrderBy: (value: string) => void; - flattenLogData: any; isFetching: boolean; isLoading: boolean; isError: boolean; isSuccess: boolean; queryStats: WsDataEvent | undefined; + minTime: number; + maxTime: number; }): JSX.Element { - const [showFormatMenuItems, setShowFormatMenuItems] = useState(false); - const menuRef = useRef(null); - const { options, config } = useOptionsMenu({ storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, dataSource: DataSource.LOGS, @@ -71,18 +68,6 @@ function LogsActionsContainer({ }, ]; - const handleToggleShowFormatOptions = (): void => - setShowFormatMenuItems(!showFormatMenuItems); - - useClickOutside({ - ref: menuRef, - onClickOutside: () => { - if (showFormatMenuItems) { - setShowFormatMenuItems(false); - } - }, - }); - return (
@@ -114,27 +99,21 @@ function LogsActionsContainer({ dataSource={DataSource.LOGS} />
- -
-