Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Soni
5480aaef6d fix: add missing filtering for ip address for scalar data
In domain listing api for external api monitoring,
we have option to filter out the IP address but
it only handles timeseries and raw type data while
domain list handler returns scalar data.
2026-02-10 17:53:02 +05:30
406 changed files with 7410 additions and 32989 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
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.142.0
image: signoz/signoz-schema-migrator:v0.129.13
container_name: schema-migrator-async
command:
- async

View File

@@ -4,6 +4,7 @@ services:
container_name: signoz-otel-collector-dev
command:
- --config=/etc/otel-collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
environment:

6
.github/CODEOWNERS vendored
View File

@@ -43,12 +43,6 @@
/pkg/analytics/ @vikrantgupta25
/pkg/statsreporter/ @vikrantgupta25
# Emailing Owners
/pkg/emailing/ @vikrantgupta25
/pkg/types/emailtypes/ @vikrantgupta25
/templates/email/ @vikrantgupta25
# Querier Owners
/pkg/querier/ @srikanthccv

View File

@@ -93,13 +93,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn install
- name: generate-api-clients
run: |
cd frontend && yarn generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)

7
.gitignore vendored
View File

@@ -1,11 +1,8 @@
node_modules
# editor
.vscode
!.vscode/settings.json
.zed
.idea
deploy/docker/environment_tiny/common_test
frontend/node_modules
@@ -34,6 +31,8 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/src/constants/env.ts
.idea
**/build
**/storage
**/locust-scripts/__pycache__/
@@ -230,3 +229,5 @@ cython_debug/
pyrightconfig.json
# cursor files
frontend/.cursor/

View File

@@ -14,8 +14,5 @@
},
"[sql]": {
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
}

View File

@@ -176,6 +176,25 @@ Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen S
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
### Unsere Projektbetreuer
#### Backend
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
#### Frontend
- [Palash Gupta](https://github.com/palashgdev)
- [Yunus M](https://github.com/YounixM)
- [Rajat Dabade](https://github.com/Rajat-Dabade)
#### DevOps
- [Prashant Shahi](https://github.com/prashant-shahi)
<br /><br />
## Dokumentation

View File

@@ -221,6 +221,34 @@ We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
### Project maintainers
#### Backend
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
- [Shivanshu Raj Shrivastava](https://github.com/shivanshuraj1333)
- [Ekansh Gupta](https://github.com/eKuG)
- [Aniket Agarwal](https://github.com/aniketio-ctrl)
#### Frontend
- [Yunus M](https://github.com/YounixM)
- [Vikrant Gupta](https://github.com/vikrantgupta25)
- [Sagar Rajput](https://github.com/SagarRajput-7)
- [Shaheer Kochai](https://github.com/ahmadshaheer)
- [Amlan Kumar Nandy](https://github.com/amlannandy)
- [Sahil Khan](https://github.com/sawhil)
- [Aditya Singh](https://github.com/aks07)
- [Abhi Kumar](https://github.com/ahrefabhi)
#### DevOps
- [Prashant Shahi](https://github.com/prashant-shahi)
- [Vibhu Pandey](https://github.com/therealpandey)
<br /><br />

View File

@@ -187,6 +187,25 @@ Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metric
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
### 项目维护人员
#### 后端
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
#### 前端
- [Palash Gupta](https://github.com/palashgdev)
- [Yunus M](https://github.com/YounixM)
- [Rajat Dabade](https://github.com/Rajat-Dabade)
#### 运维开发
- [Prashant Shahi](https://github.com/prashant-shahi)
<br /><br />
## 文档

View File

@@ -193,15 +193,6 @@ emailing:
templates:
# The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go.
directory: /opt/signoz/conf/templates/email
format:
header:
enabled: false
logo_url: ""
help:
enabled: false
email: ""
footer:
enabled: false
smtp:
# The SMTP server address.
address: localhost:25
@@ -294,6 +285,7 @@ flagger:
config:
boolean:
use_span_metrics: true
interpolation_enabled: false
kafka_span_eval: false
string:
float:
@@ -308,14 +300,3 @@ user:
allow_self: true
# The duration within which a user can reset their password.
max_token_lifetime: 6h
root:
# Whether to enable the root user. When enabled, a root user is provisioned
# on startup using the email and password below. The root user cannot be
# deleted, updated, or have their password changed through the UI.
enabled: false
# The email address of the root user.
email: ""
# The password of the root user. Must meet password requirements.
password: ""
# The name of the organization to create or look up for the root user.
org_name: default

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.112.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,11 +209,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -232,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.112.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,11 +150,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
configs:
- source: otel-collector-config
target: /etc/otel-collector-config.yaml
@@ -175,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -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.112.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,12 +213,13 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -238,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -249,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

View File

@@ -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.112.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,12 +144,13 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -165,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -177,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -46,7 +45,7 @@ type APIHandler struct {
}
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
RuleManager: opts.RulesManager,
@@ -59,7 +58,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
})
if err != nil {
return nil, err
@@ -107,10 +106,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
// v5
router.Handle("/api/v5/query_range", handler.New(
am.ViewAccess(ah.queryRangeV5),
querierAPI.QueryRangeV5OpenAPIDef,
)).Methods(http.MethodPost)
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)

View File

@@ -175,7 +175,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
GlobalConfig: config.Global,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
if err != nil {
return nil, err
}

View File

@@ -1,74 +0,0 @@
---
description: Core testing conventions - imports, rendering, MSW, interactions, queries
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Testing Conventions
## Imports
Always import from the test harness, never directly from `@testing-library/react`:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
```
## Router
Use the built-in router in `render`:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on their values.
## 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 response fixtures in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
- Always `await` interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
## 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` > visible text > `data-testid` (last resort).
## Anti-patterns
- Never import from `@testing-library/react` directly
- Never use global fake timers
- Never wrap `render` in `act(...)`
- Never mock infra dependencies locally (router, react-query)
- Limit to 3-5 focused tests per file

View File

@@ -1,54 +0,0 @@
---
description: When to use global vs local mocks in tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Mock Strategy
## Use Global Mocks For
High-frequency dependencies (20+ test files):
- Core infrastructure: react-router-dom, react-query, antd
- Browser APIs: ResizeObserver, matchMedia, localStorage
- Utility libraries: date-fns, lodash
Available global mock files (from jest.config.ts):
- `uplot` -> `__mocks__/uplotMock.ts`
## Use Local Mocks For
- Business logic dependencies (API endpoints, custom hooks, domain components)
- Test-specific behavior (different data per test, error scenarios, loading states)
## Decision Tree
```
Used in 20+ test files?
YES -> Global mock
NO -> Business logic or test-specific?
YES -> Local mock
NO -> Consider global if usage grows
```
## Correct Usage
```ts
// Global mocks are already available - just import
import { useLocation } from 'react-router-dom';
// Local mocks for business logic
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData),
}));
```
## Anti-patterns
```ts
// Never re-mock globally mocked dependencies locally
jest.mock('react-router-dom', () => ({ ... }));
// Never put test-specific data in global mocks
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
```

View File

@@ -1,54 +0,0 @@
---
description: TypeScript type safety requirements for Jest tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# TypeScript Type Safety in Tests
All Jest tests must be fully type-safe. Never use `any`.
## Mock Function Typing
```ts
// Use jest.mocked for module mocks
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
// Use jest.MockedFunction for standalone mocks
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
```
## Mock Data
Define interfaces for all mock data:
```ts
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
const mockProps: ComponentProps = {
title: 'Test',
data: [mockUser],
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
};
```
## Hook Mocking Pattern
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## Checklist
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
- All mock data has proper interfaces
- No `any` types in test files
- Component props are typed
- API response types are defined

484
frontend/.cursorrules Normal file
View File

@@ -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<T>`
- 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(<Page />, 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(<MyComponent />, 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**: 35 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(<MyComponent />, 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<T> {
data: T;
status: number;
message: string;
}
// Type the mock functions
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise<ApiResponse<User>>>;
// 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<ComponentProps> = ({ 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(<TestComponent {...mockProps} />);
// 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> = {}): 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": "<rootDir>/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<typeof apiCall>;
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<T> {
data: T;
status: number;
}
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
// Usage
mockFetchData<User>('/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<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = {
title: 'Test',
data: [],
onSelect: jest.fn(),
...props
};
return render(<TestComponent {...defaultProps} />);
};
```
### Error Handling with Types
```ts
// ✅ GOOD - Typed error handling
interface ApiError {
message: string;
code: number;
details?: Record<string, unknown>;
}
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<T>`
- [ ] 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)
}));
```

View File

@@ -12,8 +12,6 @@ export interface MockUPlotInstance {
export interface MockUPlotPaths {
spline: jest.Mock;
bars: jest.Mock;
linear: jest.Mock;
stepped: jest.Mock;
}
// Create mock instance methods
@@ -25,23 +23,10 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
setSeries: jest.fn(),
});
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
jest.fn(() => ({
name, // To test if the correct pathBuilder is used
stroke: jest.fn(),
fill: jest.fn(),
clip: jest.fn(),
}));
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
const mockPaths = {
spline: jest.fn(() => createMockPathBuilder('spline')),
bars: jest.fn(() => createMockPathBuilder('bars')),
linear: jest.fn(() => createMockPathBuilder('linear')),
stepped: jest.fn((opts?: { align?: number }) =>
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
),
// Create mock paths
const mockPaths: MockUPlotPaths = {
spline: jest.fn(),
bars: jest.fn(),
};
// Mock static methods

View File

@@ -17,8 +17,6 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^@signozhq/icons$':
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
},
globals: {
extensionsToTreatAsEsm: ['.ts'],

View File

@@ -19,7 +19,7 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh && prettier --write src/api/generated && (eslint --fix src/api/generated || true)"
},
"engines": {
"node": ">=16.15.0"
@@ -52,13 +52,10 @@
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="188" fill="none" viewBox="0 0 184 188"><path fill="#f3b01c" d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122"/><path fill="#8d2676" d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555"/><path fill="#ee342f" d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704"/></svg>

Before

Width:  |  Height:  |  Size: 811 B

View File

@@ -1,7 +1,6 @@
{
"SIGN_UP": "SigNoz | Sign Up",
"LOGIN": "SigNoz | Login",
"FORGOT_PASSWORD": "SigNoz | Forgot Password",
"HOME": "SigNoz | Home",
"SERVICE_METRICS": "SigNoz | Service Metrics",
"SERVICE_MAP": "SigNoz | Service Map",

View File

@@ -1,6 +1,5 @@
#!/bin/bash
echo "\n\n---\nRenamed tag files to index.ts...\n"
# Rename tag files to index.ts in services directories
# tags-split creates: services/tagName/tagName.ts -> rename to services/tagName/index.ts
find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
@@ -12,33 +11,4 @@ find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r
fi
done
echo "\n✅ Tag files renamed to index.ts"
# Format generated files
echo "\n\n---\nRunning prettier...\n"
if ! prettier --write src/api/generated; then
echo "Prettier formatting failed!"
exit 1
fi
echo "\n✅ Prettier formatting successful"
# Fix linting issues
echo "\n\n---\nRunning eslint...\n"
if ! yarn lint --fix --quiet src/api/generated; then
echo "ESLint check failed! Please fix linting errors before proceeding."
exit 1
fi
echo "\n✅ ESLint check successful"
# Check for type errors
echo "\n\n---\nChecking for type errors...\n"
if ! tsc --noEmit; then
echo "Type check failed! Please fix type errors before proceeding."
exit 1
fi
echo "\n✅ Type check successful"
echo "\n\n---\n ✅✅✅ API generation complete!"
echo "Tag files renamed to index.ts"

View File

@@ -194,10 +194,6 @@ export const Login = Loadable(
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
);
export const ForgotPassword = Loadable(
() => import(/* webpackChunkName: "ForgotPassword" */ 'pages/ForgotPassword'),
);
export const UnAuthorized = Loadable(
() => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'),
);

View File

@@ -17,7 +17,6 @@ import {
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
Home,
InfrastructureMonitoring,
InstalledIntegrations,
@@ -340,13 +339,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,

View File

@@ -11,7 +11,6 @@ import {
const dashboardVariablesQuery = async (
props: Props,
signal?: AbortSignal,
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
try {
const { globalTime } = store.getState();
@@ -33,7 +32,7 @@ const dashboardVariablesQuery = async (
payload.variables = { ...payload.variables, ...timeVariables };
const response = await axios.post(`/variables/query`, payload, { signal });
const response = await axios.post(`/variables/query`, payload);
return {
statusCode: 200,

View File

@@ -19,7 +19,6 @@ export const getFieldValues = async (
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
abortSignal?: AbortSignal,
): Promise<SuccessResponseV2<FieldValueResponse>> => {
const params: Record<string, string> = {};
@@ -48,10 +47,7 @@ export const getFieldValues = async (
}
try {
const response = await axios.get('/fields/values', {
params,
signal: abortSignal,
});
const response = await axios.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {

View File

@@ -1,222 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns field keys
* @summary Get field keys
*/
export const getFieldsKeys = (
params?: GetFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsKeys200>({
url: `/api/v1/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsKeysQueryKey = (params?: GetFieldsKeysParams) => {
return ['getFieldsKeys', ...(params ? [params] : [])] as const;
};
export const getGetFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsKeysQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsKeys>>> = ({
signal,
}) => getFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsKeys>>
>;
export type GetFieldsKeysQueryError = RenderErrorResponseDTO;
/**
* @summary Get field keys
*/
export function useGetFieldsKeys<
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field keys
*/
export const invalidateGetFieldsKeys = async (
queryClient: QueryClient,
params?: GetFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns field values
* @summary Get field values
*/
export const getFieldsValues = (
params?: GetFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsValues200>({
url: `/api/v1/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsValuesQueryKey = (params?: GetFieldsValuesParams) => {
return ['getFieldsValues', ...(params ? [params] : [])] as const;
};
export const getGetFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsValuesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsValues>>> = ({
signal,
}) => getFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsValues>>
>;
export type GetFieldsValuesQueryError = RenderErrorResponseDTO;
/**
* @summary Get field values
*/
export function useGetFieldsValues<
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsValuesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field values
*/
export const invalidateGetFieldsValues = async (
queryClient: QueryClient,
params?: GetFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -20,20 +20,17 @@ import { useMutation, useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetMetricAlerts200,
GetMetricAlertsPathParameters,
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricAttributesPathParameters,
GetMetricDashboards200,
GetMetricDashboardsPathParameters,
GetMetricDashboardsParams,
GetMetricHighlights200,
GetMetricHighlightsPathParameters,
GetMetricHighlightsParams,
GetMetricMetadata200,
GetMetricMetadataPathParameters,
GetMetricMetadataParams,
GetMetricsStats200,
GetMetricsTreemap200,
ListMetrics200,
ListMetricsParams,
MetricsexplorertypesMetricAttributesRequestDTO,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
@@ -46,128 +43,30 @@ type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns a list of distinct metric names within the specified time range
* @summary List metric names
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
*/
export const listMetrics = (
params?: ListMetricsParams,
export const getMetricAlerts = (
params: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListMetrics200>({
url: `/api/v2/metrics`,
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metric/alerts`,
method: 'GET',
params,
signal,
});
};
export const getListMetricsQueryKey = (params?: ListMetricsParams) => {
return ['listMetrics', ...(params ? [params] : [])] as const;
};
export const getListMetricsQueryOptions = <
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMetricsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMetrics>>> = ({
signal,
}) => listMetrics(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricsQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetrics>>
>;
export type ListMetricsQueryError = RenderErrorResponseDTO;
/**
* @summary List metric names
*/
export function useListMetrics<
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List metric names
*/
export const invalidateListMetrics = async (
queryClient: QueryClient,
params?: ListMetricsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
*/
export const getMetricAlerts = (
{ metricName }: GetMetricAlertsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metrics/${metricName}/alerts`,
method: 'GET',
signal,
});
};
export const getGetMetricAlertsQueryKey = ({
metricName,
}: GetMetricAlertsPathParameters) => {
return ['getMetricAlerts'] as const;
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
return ['getMetricAlerts', ...(params ? [params] : [])] as const;
};
export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -178,19 +77,13 @@ export const getGetMetricAlertsQueryOptions = <
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
signal,
}) => getMetricAlerts({ metricName }, signal);
}) => getMetricAlerts(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
TError,
TData
@@ -210,7 +103,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -219,7 +112,7 @@ export function useGetMetricAlerts<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -235,126 +128,11 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
{ metricName }: GetMetricAlertsPathParameters,
params: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/${metricName}/attributes`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAttributesQueryKey = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
) => {
return ['getMetricAttributes', ...(params ? [params] : [])] as const;
};
export const getGetMetricAttributesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMetricAttributesQueryKey({ metricName }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricAttributes>>
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricAttributesQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export function useGetMetricAttributes<
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAttributesQueryOptions(
{ metricName },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric attributes
*/
export const invalidateGetMetricAttributes = async (
queryClient: QueryClient,
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
{ queryKey: getGetMetricAlertsQueryKey(params) },
options,
);
@@ -366,27 +144,28 @@ export const invalidateGetMetricAttributes = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metrics/${metricName}/dashboards`,
url: `/api/v2/metric/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = ({
metricName,
}: GetMetricDashboardsPathParameters) => {
return ['getMetricDashboards'] as const;
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return ['getMetricDashboards', ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -398,18 +177,13 @@ export const getGetMetricDashboardsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
> = ({ signal }) => getMetricDashboards(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
@@ -429,7 +203,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -438,10 +212,7 @@ export function useGetMetricDashboards<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -457,11 +228,11 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
{ metricName }: GetMetricDashboardsPathParameters,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
{ queryKey: getGetMetricDashboardsQueryKey(params) },
options,
);
@@ -473,27 +244,28 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
url: `/api/v2/metrics/${metricName}/highlights`,
url: `/api/v2/metric/highlights`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricHighlightsQueryKey = ({
metricName,
}: GetMetricHighlightsPathParameters) => {
return ['getMetricHighlights'] as const;
export const getGetMetricHighlightsQueryKey = (
params?: GetMetricHighlightsParams,
) => {
return ['getMetricHighlights', ...(params ? [params] : [])] as const;
};
export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -505,18 +277,13 @@ export const getGetMetricHighlightsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricHighlights>>
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
> = ({ signal }) => getMetricHighlights(params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
TError,
TData
@@ -536,7 +303,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -545,10 +312,7 @@ export function useGetMetricHighlights<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricHighlightsQueryOptions(
{ metricName },
options,
);
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -564,115 +328,11 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
{ metricName }: GetMetricHighlightsPathParameters,
params: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
{ metricName }: GetMetricMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'GET',
signal,
});
};
export const getGetMetricMetadataQueryKey = ({
metricName,
}: GetMetricMetadataPathParameters) => {
return ['getMetricMetadata'] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
{ metricName }: GetMetricMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
{ queryKey: getGetMetricHighlightsQueryKey(params) },
options,
);
@@ -778,6 +438,189 @@ export const useUpdateMetricMetadata = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
metricsexplorertypesMetricAttributesRequestDTO: MetricsexplorertypesMetricAttributesRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/attributes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesMetricAttributesRequestDTO,
signal,
});
};
export const getGetMetricAttributesMutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationKey = ['getMetricAttributes'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getMetricAttributes>>,
{ data: MetricsexplorertypesMetricAttributesRequestDTO }
> = (props) => {
const { data } = props ?? {};
return getMetricAttributes(data);
};
return { mutationFn, ...mutationOptions };
};
export type GetMetricAttributesMutationResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesMutationBody = MetricsexplorertypesMetricAttributesRequestDTO;
export type GetMetricAttributesMutationError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export const useGetMetricAttributes = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationOptions = getGetMetricAttributesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/metadata`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricMetadataQueryKey = (
params?: GetMetricMetadataParams,
) => {
return ['getMetricMetadata', ...(params ? [params] : [])] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
* @summary Get metrics statistics

View File

@@ -1,107 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.
* @summary Query range
*/
export const queryRangeV5 = (
querybuildertypesv5QueryRangeRequestDTO: Querybuildertypesv5QueryRangeRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangeV5200>({
url: `/api/v5/query_range`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
signal,
});
};
export const getQueryRangeV5MutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationKey = ['queryRangeV5'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof queryRangeV5>>,
{ data: Querybuildertypesv5QueryRangeRequestDTO }
> = (props) => {
const { data } = props ?? {};
return queryRangeV5(data);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangeV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangeV5>>
>;
export type QueryRangeV5MutationBody = Querybuildertypesv5QueryRangeRequestDTO;
export type QueryRangeV5MutationError = RenderErrorResponseDTO;
/**
* @summary Query range
*/
export const useQueryRangeV5 = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationOptions = getQueryRangeV5MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,13 @@ export const getMetricMetadata = async (
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
signal,
headers,
});
const response = await axios.get(
`/metrics/metadata?metricName=${encodedMetricName}`,
{
signal,
headers,
},
);
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDomain;

View File

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
const put = async (
props: UpdatableAuthDomain,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<RawSuccessResponse<null>>(
`/domains/${props.id}`,
{ config: props.config },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default put;

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
const listAllDomain = async (): Promise<
SuccessResponseV2<GettableAuthDomain[]>
> => {
try {
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
`/domains`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listAllDomain;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
const post = async (
props: PostableAuthDomain,
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
try {
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
`/domains`,
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -73,7 +73,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'time_series',
data: { results: [timeSeries] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
};
const params = makeBaseParams('time_series', [
@@ -156,7 +156,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
};
const params = makeBaseParams('scalar', [
@@ -239,7 +239,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
};
const params = makeBaseParams('scalar', [

View File

@@ -388,7 +388,6 @@ export function convertV5ResponseToLegacy(
warnings: v5Data?.data?.warning || [],
},
warning: v5Data?.warning || undefined,
meta: v5Data?.meta,
},
warning: v5Data?.warning || undefined,
};
@@ -407,7 +406,6 @@ export function convertV5ResponseToLegacy(
payload: {
data: convertedData,
warning: v5Response.payload?.data?.warning || undefined,
meta: v5Data?.meta,
},
};

View File

@@ -18,12 +18,9 @@ import '@signozhq/checkbox';
import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -1,9 +1,8 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -11,8 +10,6 @@ import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -31,15 +28,6 @@ interface Props {
}
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
@@ -84,40 +72,29 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
setIsPaginating(false);
}, [data, setIsPaginating]);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
/>
),
[],
);
const renderFooter = useCallback(
@@ -141,7 +118,6 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
@@ -163,24 +139,7 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div
className="host-metrics-logs-list-container"
data-log-detail-ignore="true"
>
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
<div className="host-metrics-logs-list-container">{renderContent}</div>
)}
</div>
);

View File

@@ -13,9 +13,6 @@ export type LogDetailProps = {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
logs?: ILog[];
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (logId: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -15,8 +15,6 @@
}
.log-detail-drawer__title-right {
display: flex;
align-items: center;
.ant-btn {
display: flex;
align-items: center;
@@ -68,10 +66,6 @@
margin-bottom: 16px;
}
.log-detail-drawer__content {
height: 100%;
}
.log-detail-drawer__log {
width: 100%;
display: flex;
@@ -189,115 +183,9 @@
.ant-drawer-close {
padding: 0px;
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-200);
background: var(--bg-ink-400);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-200);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
.log-arrows {
display: flex;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 2px 6px;
align-items: center;
margin-left: 8px;
}
.log-arrow-btn {
padding: 0;
min-width: 28px;
height: 28px;
border-radius: 4px;
background: var(--bg-ink-400);
color: var(--text-vanilla-400);
border: 1px solid var(--bg-ink-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-ink-400);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
.log-arrow-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
background: var(--bg-ink-500);
color: var(--text-vanilla-200);
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
}
}
.lightMode {
.log-arrows {
background: var(--bg-vanilla-100);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn {
background: var(--bg-vanilla-100);
color: var(--text-ink-400);
border: 1px solid var(--bg-vanilla-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-vanilla-100);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn[disabled] {
background: var(--bg-vanilla-100);
color: var(--text-ink-200);
}
.ant-drawer-header {
border-bottom: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100);
@@ -364,33 +252,4 @@
color: var(--text-ink-300);
}
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-700);
background: var(--bg-vanilla-100);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-700);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
@@ -32,12 +32,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
BarChart2,
Braces,
ChevronDown,
ChevronUp,
Compass,
Copy,
Filter,
@@ -64,9 +60,6 @@ function LogDetailInner({
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
onScrollToLog,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -81,78 +74,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
// Don't close if clicking on explicitly ignored regions
if (target.closest('[data-log-detail-ignore="true"]')) {
return;
}
// Close the drawer for any other outside click
onClose?.(e as any);
};
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Keyboard navigation - handle up/down arrow keys
// Only listen when in OVERVIEW tab
useEffect(() => {
if (
!logs ||
!onNavigateLog ||
logs.length === 0 ||
selectedView !== VIEW_TYPES.OVERVIEW
) {
return;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const currentIndex = logs.findIndex((l) => l.id === log.id);
if (currentIndex === -1) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
// Navigate to previous log
if (currentIndex > 0) {
const prevLog = logs[currentIndex - 1];
onNavigateLog(prevLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(prevLog.id);
}
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
// Navigate to next log
if (currentIndex < logs.length - 1) {
const nextLog = logs[currentIndex + 1];
onNavigateLog(nextLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(nextLog.id);
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [log.id, logs, onNavigateLog, onScrollToLog, selectedView]);
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) {
return null;
@@ -306,87 +227,32 @@ function LogDetailInner({
);
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
!logs || !onNavigateLog || logs.length === 0 || currentLogIndex <= 0;
const isNextDisabled =
!logs ||
!onNavigateLog ||
logs.length === 0 ||
currentLogIndex === logs.length - 1;
type HandleNavigateLogParams = {
direction: 'next' | 'previous';
};
const handleNavigateLog = ({ direction }: HandleNavigateLogParams): void => {
if (!logs || !onNavigateLog || currentLogIndex === -1) {
return;
}
if (direction === 'previous' && !isPrevDisabled) {
const prevLog = logs[currentLogIndex - 1];
onNavigateLog(prevLog);
onScrollToLog?.(prevLog.id);
} else if (direction === 'next' && !isNextDisabled) {
const nextLog = logs[currentLogIndex + 1];
onNavigateLog(nextLog);
onScrollToLog?.(nextLog.id);
}
};
return (
<Drawer
width="60%"
mask={false}
maskClosable={false}
maskStyle={{ background: 'none' }}
title={
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
{showOpenInExplorerBtn && (
<div className="log-detail-drawer__title-right">
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
>
<Button
icon={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
icon={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={(): void => handleNavigateLog({ direction: 'next' })}
/>
</Tooltip>
Open in Explorer
</Button>
</div>
{showOpenInExplorerBtn && (
<div>
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
)}
</div>
}
placement="right"
// closable
onClose={drawerCloseHandler}
open={log !== null}
style={{
@@ -397,164 +263,138 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
>
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
title="Show Filters"
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</Tooltip>
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
</Drawer>
);
}

View File

@@ -2,11 +2,13 @@ import { memo, useCallback, useMemo } from 'react';
import { blue } from '@ant-design/colors';
import { Typography } from 'antd';
import cx from 'classnames';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
@@ -102,17 +104,12 @@ function LogSelectedField({
type ListLogViewProps = {
logData: ILog;
selectedFields: IField[];
onSetActiveLog: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onSetActiveLog: (log: ILog) => void;
onAddToQuery: AddToQueryHOCProps['onAddToQuery'];
activeLog?: ILog | null;
linesPerRow: number;
fontSize: FontSize;
handleChangeSelectedView?: ChangeViewFunctionType;
isActiveLog?: boolean;
onClearActiveLog?: () => void;
};
function ListLogView({
@@ -123,8 +120,7 @@ function ListLogView({
activeLog,
linesPerRow,
fontSize,
isActiveLog,
onClearActiveLog,
handleChangeSelectedView,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
@@ -133,24 +129,35 @@ function ListLogView({
);
const isReadOnlyLog = !isLogsExplorerPage;
const {
activeLog: activeContextLog,
onAddToQuery: handleAddToQuery,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
} = useActiveLog();
const isDarkMode = useIsDarkMode();
const handleDetailedView = useCallback(() => {
if (isActiveLog) {
onClearActiveLog?.();
return;
}
const handlerClearActiveContextLog = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
handleClearActiveContextLog();
},
[handleClearActiveContextLog],
);
const handleDetailedView = useCallback(() => {
onSetActiveLog(logData);
}, [logData, onSetActiveLog, isActiveLog, onClearActiveLog]);
}, [logData, onSetActiveLog]);
const handleShowContext = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog(logData, VIEW_TYPES.CONTEXT);
handleSetActiveContextLog(logData);
},
[logData, onSetActiveLog],
[logData, handleSetActiveContextLog],
);
const updatedSelecedFields = useMemo(
@@ -179,7 +186,11 @@ function ListLogView({
return (
<>
<Container
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
$isActiveLog={
isHighlighted ||
activeLog?.id === logData.id ||
activeContextLog?.id === logData.id
}
$isDarkMode={isDarkMode}
$logType={logType}
onClick={handleDetailedView}
@@ -240,6 +251,15 @@ function ListLogView({
/>
)}
</Container>
{activeContextLog && (
<LogDetail
log={activeContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
onClose={handlerClearActiveContextLog}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</>
);
}

View File

@@ -1,15 +1,19 @@
import {
KeyboardEvent,
memo,
MouseEvent,
MouseEventHandler,
useCallback,
useMemo,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
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';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -35,8 +39,7 @@ function RawLogView({
selectedFields = [],
fontSize,
onLogClick,
onSetActiveLog,
onClearActiveLog,
handleChangeSelectedView,
}: RawLogViewProps): JSX.Element {
const {
isHighlighted: isUrlHighlighted,
@@ -45,6 +48,15 @@ function RawLogView({
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<VIEWS | undefined>();
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -122,24 +134,34 @@ function RawLogView({
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
onLogClick(data, event);
return;
} else {
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}
if (isActiveLog) {
onClearActiveLog?.();
return;
}
onSetActiveLog?.(data);
},
[isReadOnly, onLogClick, isActiveLog, onSetActiveLog, data, onClearActiveLog],
[isReadOnly, data, onSetActiveLog, onLogClick],
);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
(
event: MouseEvent<Element, globalThis.MouseEvent> | KeyboardEvent<Element>,
) => {
event.preventDefault();
event.stopPropagation();
onClearActiveLog();
setSelectedTab(undefined);
},
[onClearActiveLog],
);
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog?.(data, VIEW_TYPES.CONTEXT);
// handleSetActiveContextLog(data);
setSelectedTab(VIEW_TYPES.CONTEXT);
onSetActiveLog(data);
},
[data, onSetActiveLog],
);
@@ -159,7 +181,7 @@ function RawLogView({
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isUrlHighlighted}
$isActiveLog={isActiveLog}
$isActiveLog={activeLog?.id === data.id || isActiveLog}
$isCustomHighlighted={isHighlighted}
$logType={logType}
fontSize={fontSize}
@@ -196,6 +218,17 @@ function RawLogView({
onLogCopy={onLogCopy}
/>
)}
{selectedTab && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</RawLogViewContainer>
);
}

View File

@@ -45,6 +45,9 @@ export const RawLogViewContainer = styled(Row)<{
: `margin: 2px 0;`}
}
${({ $isActiveLog, $logType }): string =>
getActiveLogBackground($isActiveLog, true, $logType)}
${({ $isReadOnly, $isActiveLog, $isDarkMode, $logType }): string =>
$isActiveLog
? getActiveLogBackground($isActiveLog, $isDarkMode, $logType)

View File

@@ -1,5 +1,4 @@
import { MouseEvent } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
@@ -17,11 +16,6 @@ export interface RawLogViewProps {
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
handleChangeSelectedView?: ChangeViewFunctionType;
onSetActiveLog?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onClearActiveLog?: () => void;
}
export interface RawLogContentProps {

View File

@@ -73,7 +73,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -1682,7 +1681,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -1700,17 +1698,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && !waitingMessage && (
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
@@ -1732,7 +1720,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -1775,7 +1762,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDarkMode,
isDynamicVariable,
showRetryButton,
waitingMessage,
]);
// Custom handler for dropdown visibility changes

View File

@@ -63,7 +63,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showIncompleteDataMessage = false,
showRetryButton = true,
isDynamicVariable = false,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -569,7 +568,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -585,16 +583,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
@@ -617,7 +605,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -654,7 +641,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showRetryButton,
isDarkMode,
isDynamicVariable,
waitingMessage,
]);
// Handle dropdown visibility changes

View File

@@ -78,10 +78,12 @@ function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
let mockSetVariablesToGetUpdated: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
mockSetVariablesToGetUpdated = jest.fn();
jest.clearAllMocks();
});
@@ -100,6 +102,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -145,6 +150,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -187,6 +195,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -236,6 +247,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -258,6 +272,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -291,6 +308,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -324,6 +344,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -346,6 +369,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -379,6 +405,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -432,6 +461,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -476,6 +508,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -513,6 +548,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -544,6 +582,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);

View File

@@ -30,7 +30,6 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
showIncompleteDataMessage?: boolean;
showRetryButton?: boolean;
isDynamicVariable?: boolean;
waitingMessage?: string;
}
export interface CustomTagProps {
@@ -67,5 +66,4 @@ export interface CustomMultiSelectProps
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
waitingMessage?: string;
}

View File

@@ -648,13 +648,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
) : (
<Typography.Text
className="value-string"
ellipsis={{
tooltip: {
placement: 'top',
mouseEnterDelay: 0.2,
mouseLeaveDelay: 0,
},
}}
ellipsis={{ tooltip: { placement: 'top' } }}
>
{String(value)}
</Typography.Text>

View File

@@ -282,11 +282,11 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP ?? true}
onChange={(checked): void => {
onClick={(): void => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: checked,
showIP: !(showIP ?? true),
});
setParams({ showIP: checked });
setParams({ showIP });
}}
/>
</div>

View File

@@ -1,8 +1,4 @@
import { ENVIRONMENT } from 'constants/env';
import {
ApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
@@ -22,15 +18,10 @@ import { QuickFiltersConfig } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/ApiMonitoring/queryParams');
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void
>;
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
@@ -93,28 +84,6 @@ TestQuickFilters.defaultProps = {
config: QuickFiltersConfig,
};
function TestQuickFiltersApiMonitoring({
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
source={QuickFiltersSource.API_MONITORING}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
);
}
TestQuickFiltersApiMonitoring.defaultProps = {
signal: '',
config: QuickFiltersConfig,
};
beforeAll(() => {
server.listen();
});
@@ -143,10 +112,6 @@ beforeEach(() => {
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
mockUseApiMonitoringParams.mockReturnValue([
{ showIP: true } as ApiMonitoringParams,
mockSetApiMonitoringParams,
]);
setupServer();
});
@@ -286,24 +251,6 @@ describe('Quick Filters', () => {
);
});
});
it('toggles Show IP addresses and updates API Monitoring params', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFiltersApiMonitoring />);
// Switch should be rendered and initially checked
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
await user.click(toggle);
await waitFor(() => {
expect(mockSetApiMonitoringParams).toHaveBeenCalledWith(
expect.objectContaining({ showIP: false }),
);
});
});
});
describe('Quick Filters with custom filters', () => {

View File

@@ -1,7 +1,6 @@
const ROUTES = {
SIGN_UP: '/signup',
LOGIN: '/login',
FORGOT_PASSWORD: '/forgot-password',
HOME: '/home',
SERVICE_METRICS: '/services/:servicename',
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',

View File

@@ -14,6 +14,6 @@ export const VIEW_TYPES = {
export const SPAN_ATTRIBUTES = {
URL_PATH: 'http.url',
RESPONSE_STATUS_CODE: 'response_status_code',
SERVER_NAME: 'http_host',
SERVER_NAME: 'net.peer.name',
SERVER_PORT: 'net.peer.port',
} as const;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table } from 'antd';
import { Spin, Table, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -14,13 +14,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { MoveUpRight } from 'lucide-react';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
@@ -127,67 +125,51 @@ function DomainList(): JSX.Element {
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Table
className={cx('api-monitoring-domain-list-table')}
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText:
isFetching || isLoading ? null : (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<div className="no-filtered-domains-message">
<div className="no-domain-title">
No External API calls detected with applied filters.
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
<div className="no-domain-subtitle">
Ensure all HTTP client spans are being sent with kind as{' '}
<span className="attribute">Client</span> and url set in{' '}
<span className="attribute">url.full</span> or{' '}
<span className="attribute">http.url</span> attribute.
</div>
<a
href={DOCLINKS.EXTERNAL_API_MONITORING}
target="_blank"
rel="noreferrer"
className="external-api-doc-link"
>
Learn how External API monitoring works in SigNoz{' '}
<MoveUpRight size={14} />
</a>
</div>
</div>
</div>
)}
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
<Table
className="api-monitoring-domain-list-table"
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
)}
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
{selectedDomainIndex !== -1 && (
<DomainDetails
domainData={formattedDataForTable[selectedDomainIndex]}

View File

@@ -180,59 +180,10 @@
.no-filtered-domains-message {
margin-top: 8px;
display: flex;
gap: 8px;
flex-direction: column;
.no-domain-title {
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.no-domain-subtitle {
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.attribute {
font-family: 'Space Mono';
}
}
.external-api-doc-link {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
}
}
.lightMode {
.no-filtered-domains-message-container {
.no-filtered-domains-message-content {
.no-filtered-domains-message {
.no-domain-title {
color: var(--text-ink-500);
}
.no-domain-subtitle {
color: var(--text-ink-400);
.attribute {
font-family: 'Space Mono';
}
}
}
}
}
.api-monitoring-domain-list-table {
.ant-table {
.ant-table-thead > tr > th {

View File

@@ -4,7 +4,6 @@ import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
@@ -216,7 +215,7 @@ describe('TopErrors', () => {
value: 'true',
}),
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.SERVER_NAME }),
key: expect.objectContaining({ key: 'net.peer.name' }),
op: '=',
value: 'test-domain',
}),

View File

@@ -638,7 +638,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
type: 'tag',
},
op: '=',
value: domainName,
@@ -685,7 +685,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
type: 'tag',
},
op: '=',
value: domainName,
@@ -733,7 +733,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
type: 'tag',
},
op: '=',
value: domainName,
@@ -780,7 +780,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
type: 'tag',
},
op: '=',
value: domainName,
@@ -1302,7 +1302,7 @@ export const getTopErrorsCoRelationQueryFilters = (
{
id: 'e8a043b7',
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
key: 'net.peer.name',
dataType: DataTypes.String,
type: '',
},
@@ -2198,7 +2198,7 @@ export const getEndPointZeroStateQueryPayload = (
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
type: 'tag',
},
op: '=',
value: domainName,
@@ -2793,7 +2793,7 @@ export const getStatusCodeBarChartWidgetData = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
type: 'tag',
},
op: '=',
value: domainName,

View File

@@ -9,6 +9,74 @@
padding: 0px;
}
.dashboard-header {
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
padding: 0px 2px;
border-radius: 2px;
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-300);
}
}
}
.dashboard-details {
display: flex;
justify-content: space-between;
@@ -467,6 +535,15 @@
.dashboard-description-container {
color: var(--bg-ink-400);
.dashboard-header {
border-bottom: 1px solid var(--bg-vanilla-300);
.dashboard-breadcrumbs {
.dashboard-btn {
color: var(--bg-ink-400);
}
}
}
.dashboard-details {
.left-section {
.dashboard-title {

View File

@@ -16,7 +16,9 @@ import {
} from 'antd';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
@@ -25,6 +27,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import {
Check,
@@ -34,6 +37,7 @@ import {
FolderKanban,
Fullscreen,
Globe,
LayoutGrid,
LockKeyhole,
PenLine,
X,
@@ -47,7 +51,6 @@ import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardGraphSlider from '../ComponentsSlider';
import DashboardSettings from '../DashboardSettings';
import { Base64Icons } from '../DashboardSettings/General/utils';
@@ -68,6 +71,7 @@ interface DashboardDescriptionProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { handle } = props;
const {
selectedDashboard,
@@ -76,6 +80,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
layouts,
setLayouts,
isDashboardLocked,
listSortOrder,
setSelectedDashboard,
handleToggleDashboardSlider,
setSelectedRowWidgetId,
@@ -287,6 +292,17 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
});
}
function goToListPage(): void {
const urlParams = new URLSearchParams();
urlParams.set('columnKey', listSortOrder.columnKey as string);
urlParams.set('order', listSortOrder.order as string);
urlParams.set('page', listSortOrder.pagination as string);
urlParams.set('search', listSortOrder.search as string);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
safeNavigate(generatedUrl);
}
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboardData,
@@ -335,7 +351,32 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
return (
<Card className="dashboard-description-container">
<DashboardHeader />
<div className="dashboard-header">
<section className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={(): void => goToListPage()}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img
src={image}
alt="dashboard-icon"
style={{ height: '14px', width: '14px' }}
/>
{title}
</Button>
</section>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />

View File

@@ -50,10 +50,6 @@
}
.variable-select {
.ant-select-selector {
overflow-y: hidden !important;
}
.ant-select-item {
display: flex;
align-items: center;

View File

@@ -9,15 +9,11 @@ import {
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
@@ -26,6 +22,8 @@ function DashboardVariableSelection(): JSX.Element | null {
const {
setSelectedDashboard,
updateLocalStorageDashboardVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
} = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
@@ -57,14 +55,11 @@ function DashboardVariableSelection(): JSX.Element | null {
[dependencyData?.order],
);
// Initialize fetch store then start a new fetch cycle.
// Runs on dependency order changes, and time range changes.
// Trigger refetch when dependency order changes or global time changes
useEffect(() => {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
initializeVariableFetchStore(allVariableNames);
enqueueFetchOfAllVariables();
if (dependencyData?.order && dependencyData.order.length > 0) {
setVariablesToGetUpdated(dependencyData?.order || []);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependencyOrderKey, minTime, maxTime]);
@@ -126,14 +121,29 @@ function DashboardVariableSelection(): JSX.Element | null {
return prev;
});
// Cascade: enqueue query-type descendants for refetching
enqueueDescendantsOfVariable(name);
if (dependencyData) {
const updatedVariables: string[] = [];
onUpdateVariableNode(
name,
dependencyData.graph,
dependencyData.order,
(node) => updatedVariables.push(node),
);
setVariablesToGetUpdated((prev) => [
...new Set([...prev, ...updatedVariables.filter((v) => v !== name)]),
]);
} else {
setVariablesToGetUpdated((prev) => prev.filter((v) => v !== name));
}
},
[
// This can be removed
dashboardVariables,
updateLocalStorageDashboardVariables,
dependencyData,
updateUrlVariable,
setSelectedDashboard,
setVariablesToGetUpdated,
],
);
@@ -148,6 +158,9 @@ function DashboardVariableSelection(): JSX.Element | null {
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
);
})}

View File

@@ -2,25 +2,18 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import useDebounce from 'hooks/useDebounce';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
import { SuccessResponseV2 } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import {
buildExistingDynamicVariableQuery,
extractErrorMessage,
getOptionsForDynamicVariable,
mergeUniqueStrings,
settleVariableFetch,
} from './util';
import { getOptionsForDynamicVariable } from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
@@ -31,6 +24,7 @@ type DynamicVariableInputProps = Pick<
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
// eslint-disable-next-line sonarjs/cognitive-complexity
function DynamicVariableInput({
variableData,
onValueUpdate,
@@ -61,8 +55,14 @@ function DynamicVariableInput({
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => mergeUniqueStrings(optionsData, relatedValues),
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
@@ -104,24 +104,67 @@ function DynamicVariableInput({
(state) => state.globalTime,
);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
// eslint-disable-next-line sonarjs/cognitive-complexity
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const existingQuery = useMemo(
() =>
buildExistingDynamicVariableQuery(
existingVariables,
variableData.id,
!!variableData.dynamicVariablesAttribute,
),
[existingVariables, variableData.id, variableData.dynamicVariablesAttribute],
);
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((val) => val !== null && val !== undefined && val !== '')
.map((val) => val.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((val) => {
// Check if value is a number
const numValue = Number(val);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return val; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${val.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
@@ -139,73 +182,6 @@ function DynamicVariableInput({
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const handleQuerySuccess = useCallback(
(data: SuccessResponseV2<FieldValueResponse>): void => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Sync temp selection with latest API values when ALL is active and dropdown is open
if (variableData.allSelected && isDropdownOpen) {
const latestValues = mergeUniqueStrings(
newNormalizedValues,
newRelatedValues,
);
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
applyDefaultIfNeeded(
mergeUniqueStrings(newNormalizedValues, newRelatedValues),
);
}
settleVariableFetch(variableData.name, 'complete');
},
[
debouncedApiSearchText,
variableData.allSelected,
variableData.name,
isDropdownOpen,
tempSelection,
setTempSelection,
applyDefaultIfNeeded,
],
);
const handleQueryError = useCallback(
(error: { message?: string } | null): void => {
if (error) {
setErrorMessage(extractErrorMessage(error));
setIsRetryableError(checkIfRetryableError(error));
}
settleVariableFetch(variableData.name, 'failure');
},
[variableData.name],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
@@ -216,22 +192,13 @@ function DynamicVariableInput({
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
variableFetchCycleId,
],
{
/*
* enabled if
* - we have dynamic variable source and attribute defined (ALWAYS)
* - AND
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled:
variableData.type === 'DYNAMIC' &&
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
queryFn: ({ signal }) =>
!!variableData.dynamicVariablesAttribute,
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
@@ -244,10 +211,70 @@ function DynamicVariableInput({
minTime,
maxTime,
existingQuery,
signal,
),
onSuccess: handleQuerySuccess,
onError: handleQueryError,
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
const allNewOptions = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
applyDefaultIfNeeded(allNewOptions);
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
@@ -309,8 +336,6 @@ function DynamicVariableInput({
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}

View File

@@ -3,9 +3,8 @@ import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty, isString } from 'lodash-es';
import { isArray, isString } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -13,18 +12,26 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, settleVariableFetch } from './util';
import { areArraysEqual, checkAPIInvocation } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'existingVariables' | 'onValueUpdate'
| 'variableData'
| 'existingVariables'
| 'onValueUpdate'
| 'variablesToGetUpdated'
| 'setVariablesToGetUpdated'
| 'dependencyData'
>;
function QueryVariableInput({
variableData,
existingVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
@@ -36,15 +43,6 @@ function QueryVariableInput({
(state) => state.globalTime,
);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const {
tempSelection,
setTempSelection,
@@ -62,6 +60,16 @@ function QueryVariableInput({
strategy: queryVariableSelectStrategy,
});
const validVariableUpdate = useCallback((): boolean => {
if (!variableData.name) {
return false;
}
return Boolean(
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
}, [variableData.name, variablesToGetUpdated]);
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
@@ -95,24 +103,18 @@ function QueryVariableInput({
valueNotInList = true;
}
if (variableData.name && (valueNotInList || variableData.allSelected)) {
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(
variableData.name,
variableData.id,
newOptionsData,
true,
);
}
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
@@ -130,11 +132,7 @@ function QueryVariableInput({
newOptionsData.every((option) => selectedValue.includes(option));
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
if (variableData.name && variableData.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
@@ -143,6 +141,10 @@ function QueryVariableInput({
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
}
}
} catch (e) {
@@ -155,6 +157,8 @@ function QueryVariableInput({
onValueUpdate,
tempSelection,
setTempSelection,
validVariableUpdate,
setVariablesToGetUpdated,
applyDefaultIfNeeded,
],
);
@@ -165,28 +169,27 @@ function QueryVariableInput({
variableData.name || '',
`${minTime}`,
`${maxTime}`,
variableFetchCycleId,
JSON.stringify(dependencyData?.order),
],
{
/*
* enabled if
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
queryFn: ({ signal }) =>
dashboardVariablesQuery(
{
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
},
signal,
enabled:
variableData &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
dependencyData?.parentDependencyGraph,
),
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete');
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
onError: (error: {
details: {
@@ -203,7 +206,9 @@ function QueryVariableInput({
}
setErrorMessage(message);
}
settleVariableFetch(variableData.name, 'failure');
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
},
);
@@ -237,8 +242,6 @@ function QueryVariableInput({
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}

View File

@@ -28,8 +28,6 @@ interface SelectVariableInputProps {
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
waiting?: boolean;
waitingMessage?: string;
}
const MAX_TAG_DISPLAY_VALUES = 10;
@@ -67,7 +65,6 @@ function SelectVariableInput({
showRetryButton,
showIncompleteDataMessage,
onSearch,
waitingMessage,
}: SelectVariableInputProps): JSX.Element {
const commonProps = useMemo(
() => ({
@@ -81,6 +78,7 @@ function SelectVariableInput({
className: 'variable-select',
popupClassName: 'dropdown-styles',
getPopupContainer: popupContainer,
style: SelectItemStyle,
showSearch: true,
bordered: false,
@@ -88,8 +86,6 @@ function SelectVariableInput({
'data-testid': 'variable-select',
onChange,
loading,
waitingMessage,
style: SelectItemStyle,
options,
errorMessage,
onRetry,
@@ -105,7 +101,6 @@ function SelectVariableInput({
defaultValue,
onChange,
loading,
waitingMessage,
options,
value,
errorMessage,

View File

@@ -47,6 +47,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -61,6 +69,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -76,6 +92,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -109,6 +133,14 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -131,6 +163,14 @@ describe('VariableItem', () => {
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -145,6 +185,14 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);

View File

@@ -1,6 +1,7 @@
import { memo } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import CustomVariableInput from './CustomVariableInput';
@@ -20,12 +21,18 @@ export interface VariableItemProps {
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dependencyData: IDependencyData | null;
}
function VariableItem({
variableData,
onValueUpdate,
existingVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
}: VariableItemProps): JSX.Element {
const { name, description, type: variableType } = variableData;
@@ -58,6 +65,9 @@ function VariableItem({
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
)}
{variableType === 'DYNAMIC' && (

View File

@@ -7,19 +7,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Don't mock the components - use real ones
// Mock for useQuery
@@ -230,10 +217,9 @@ describe('DynamicVariableInput Component', () => {
'',
'Traces',
'service.name',
0, // variableFetchCycleId
],
expect.objectContaining({
enabled: true, // isVariableFetching is true from mock
enabled: true, // Type is 'DYNAMIC'
queryFn: expect.any(Function),
onSuccess: expect.any(Function),
onError: expect.any(Function),

View File

@@ -8,6 +8,14 @@ import '@testing-library/jest-dom/extend-expect';
import VariableItem from '../VariableItem';
const mockOnValueUpdate = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
const baseDependencyData = {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
};
const TEST_VARIABLE_ID = 'test_variable';
const VARIABLE_SELECT_TESTID = 'variable-select';
@@ -23,6 +31,9 @@ const renderVariableItem = (
variableData={variableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={baseDependencyData}
/>
</MockQueryClientProvider>,
);

View File

@@ -2,12 +2,14 @@ import {
buildDependencies,
buildDependencyGraph,
buildParentDependencyGraph,
checkAPIInvocation,
onUpdateVariableNode,
VariableGraph,
} from '../util';
import {
buildDependenciesMock,
buildGraphMock,
checkAPIInvocationMock,
onUpdateVariableNodeMock,
} from './mock';
@@ -70,6 +72,97 @@ describe('dashboardVariables - utilities and processors', () => {
});
});
describe('checkAPIInvocation', () => {
const {
variablesToGetUpdated,
variableData,
parentDependencyGraph,
} = checkAPIInvocationMock;
const mockRootElement = {
name: 'deployment_environment',
key: '036a47cd-9ffc-47de-9f27-0329198964a8',
id: '036a47cd-9ffc-47de-9f27-0329198964a8',
modificationUUID: '5f71b591-f583-497c-839d-6a1590c3f60f',
selectedValue: 'production',
type: 'QUERY',
// ... other properties omitted for brevity
} as any;
describe('edge cases', () => {
it('should return false when variableData is empty', () => {
expect(
checkAPIInvocation(
variablesToGetUpdated,
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
it('should return true when parentDependencyGraph is empty', () => {
expect(
checkAPIInvocation(variablesToGetUpdated, variableData, {}),
).toBeFalsy();
});
});
describe('variable sequences', () => {
it('should return true for valid sequence', () => {
expect(
checkAPIInvocation(
['k8s_node_name', 'k8s_namespace_name'],
variableData,
parentDependencyGraph,
),
).toBeTruthy();
});
it('should return false for invalid sequence', () => {
expect(
checkAPIInvocation(
['k8s_cluster_name', 'k8s_node_name', 'k8s_namespace_name'],
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
it('should return false when variableData is not in sequence', () => {
expect(
checkAPIInvocation(
['deployment_environment', 'service_name', 'endpoint'],
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
});
describe('root element behavior', () => {
it('should return true for valid root element sequence', () => {
expect(
checkAPIInvocation(
[
'deployment_environment',
'service_name',
'endpoint',
'http_status_code',
],
mockRootElement,
parentDependencyGraph,
),
).toBeTruthy();
});
it('should return true for empty variablesToGetUpdated array', () => {
expect(
checkAPIInvocation([], mockRootElement, parentDependencyGraph),
).toBeTruthy();
});
});
});
describe('Graph Building Utilities', () => {
const { graph } = buildGraphMock;
const { variables } = buildDependenciesMock;
@@ -130,86 +223,10 @@ describe('dashboardVariables - utilities and processors', () => {
},
hasCycle: false,
cycleNodes: undefined,
transitiveDescendants: {
deployment_environment: ['service_name', 'endpoint', 'http_status_code'],
endpoint: ['http_status_code'],
environment: [],
http_status_code: [],
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
k8s_namespace_name: [],
k8s_node_name: ['k8s_namespace_name'],
service_name: ['endpoint', 'http_status_code'],
},
};
expect(buildDependencyGraph(graph)).toEqual(expected);
});
it('should return empty transitiveDescendants for an empty graph', () => {
const result = buildDependencyGraph({});
expect(result.transitiveDescendants).toEqual({});
expect(result.order).toEqual([]);
expect(result.hasCycle).toBe(false);
});
it('should compute transitiveDescendants for a linear chain (a -> b -> c)', () => {
const linearGraph: VariableGraph = {
a: ['b'],
b: ['c'],
c: [],
};
const result = buildDependencyGraph(linearGraph);
expect(result.transitiveDescendants).toEqual({
a: ['b', 'c'],
b: ['c'],
c: [],
});
});
it('should compute transitiveDescendants for a diamond dependency (a -> b, a -> c, b -> d, c -> d)', () => {
const diamondGraph: VariableGraph = {
a: ['b', 'c'],
b: ['d'],
c: ['d'],
d: [],
};
const result = buildDependencyGraph(diamondGraph);
expect(result.transitiveDescendants.a).toEqual(
expect.arrayContaining(['b', 'c', 'd']),
);
expect(result.transitiveDescendants.a).toHaveLength(3);
expect(result.transitiveDescendants.b).toEqual(['d']);
expect(result.transitiveDescendants.c).toEqual(['d']);
expect(result.transitiveDescendants.d).toEqual([]);
});
it('should handle disconnected components in transitiveDescendants', () => {
const disconnectedGraph: VariableGraph = {
a: ['b'],
b: [],
x: ['y'],
y: [],
};
const result = buildDependencyGraph(disconnectedGraph);
expect(result.transitiveDescendants.a).toEqual(['b']);
expect(result.transitiveDescendants.b).toEqual([]);
expect(result.transitiveDescendants.x).toEqual(['y']);
expect(result.transitiveDescendants.y).toEqual([]);
});
it('should return empty transitiveDescendants for all leaf nodes', () => {
const leafOnlyGraph: VariableGraph = {
a: [],
b: [],
c: [],
};
const result = buildDependencyGraph(leafOnlyGraph);
expect(result.transitiveDescendants).toEqual({
a: [],
b: [],
c: [],
});
});
});
describe('buildDependencies', () => {

View File

@@ -1,3 +1,36 @@
/* eslint-disable sonarjs/no-duplicate-string */
export const checkAPIInvocationMock = {
variablesToGetUpdated: [],
variableData: {
name: 'k8s_node_name',
key: '4d71d385-beaf-4434-8dbf-c62be68049fc',
allSelected: false,
customValue: '',
description: '',
id: '4d71d385-beaf-4434-8dbf-c62be68049fc',
modificationUUID: '77233d3c-96d7-4ccb-aa9d-11b04d563068',
multiSelect: false,
order: 6,
queryValue:
"SELECT JSONExtractString(labels, 'k8s_node_name') AS k8s_node_name\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'k8s_node_cpu_time' AND JSONExtractString(labels, 'k8s_cluster_name') = {{.k8s_cluster_name}}\nGROUP BY k8s_node_name",
selectedValue: 'gke-signoz-saas-si-consumer-bsc-e2sd4-a6d430fa-gvm2',
showALLOption: false,
sort: 'DISABLED',
textboxValue: '',
type: 'QUERY',
},
parentDependencyGraph: {
deployment_environment: [],
service_name: ['deployment_environment'],
endpoint: ['deployment_environment', 'service_name'],
http_status_code: ['endpoint'],
k8s_cluster_name: [],
environment: [],
k8s_node_name: ['k8s_cluster_name'],
k8s_namespace_name: ['k8s_cluster_name', 'k8s_node_name'],
},
} as any;
export const onUpdateVariableNodeMock = {
nodeToUpdate: 'deployment_environment',
graph: {

View File

@@ -1,16 +1,9 @@
import { OptionData } from 'components/NewSelect/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { isEmpty } from 'lodash-es';
import { isEmpty, isNull } from 'lodash-es';
import {
IDashboardVariables,
IDependencyData,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
onVariableFetchComplete,
onVariableFetchFailure,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export function areArraysEqual(
@@ -52,16 +45,30 @@ const getDependentVariablesBasedOnVariableName = (
}
return variables
.map((variable) => {
?.map((variable: any) => {
if (variable.type === 'QUERY') {
// Combined pattern for all formats
// {{.variable_name}} - original format
// $variable_name - dollar prefix format
// [[variable_name]] - square bracket format
// {{variable_name}} - without dot format
const patterns = [
`\\{\\{\\s*?\\.${variableName}\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*${variableName}\\s*\\}\\}`, // {{var}}
`\\$${variableName}\\b`, // $var
`\\[\\[\\s*${variableName}\\s*\\]\\]`, // [[var]]
];
const combinedRegex = new RegExp(patterns.join('|'));
const queryValue = variable.queryValue || '';
if (textContainsVariableReference(queryValue, variableName)) {
const dependVarReMatch = queryValue.match(combinedRegex);
if (dependVarReMatch !== null && dependVarReMatch.length > 0) {
return variable.name;
}
}
return null;
})
.filter((val): val is string => val !== null);
.filter((val: string | null) => !isNull(val));
};
export type VariableGraph = Record<string, string[]>;
@@ -239,26 +246,10 @@ export const buildDependencyGraph = (
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
// Pre-compute transitive descendants by walking topological order in reverse.
// Each node's transitive descendants = direct children + their transitive descendants.
const transitiveDescendants: VariableGraph = {};
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
const node = topologicalOrder[i];
const desc = new Set<string>();
for (const child of adjList[node] || []) {
desc.add(child);
for (const d of transitiveDescendants[child] || []) {
desc.add(d);
}
}
transitiveDescendants[node] = Array.from(desc);
}
return {
order: topologicalOrder,
graph: adjList,
parentDependencyGraph: buildParentDependencyGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
@@ -293,6 +284,33 @@ export const onUpdateVariableNode = (
});
};
export const checkAPIInvocation = (
variablesToGetUpdated: string[],
variableData: IDashboardVariable,
parentDependencyGraph?: VariableGraph,
): boolean => {
if (isEmpty(variableData.name)) {
return false;
}
if (isEmpty(parentDependencyGraph)) {
return false;
}
// if no dependency then true
const haveDependency =
parentDependencyGraph?.[variableData.name || '']?.length > 0;
if (!haveDependency) {
return true;
}
// if variable is in the list and has dependency then check if its the top element in the queue then true else false
return (
variablesToGetUpdated.length > 0 &&
variablesToGetUpdated[0] === variableData.name
);
};
export const getOptionsForDynamicVariable = (
normalizedValues: (string | number | boolean)[],
relatedValues: string[],
@@ -357,130 +375,3 @@ export const getSelectValue = (
}
return selectedValue?.toString();
};
/**
* Merges multiple arrays of values into a single deduplicated string array.
*/
export function mergeUniqueStrings(
...arrays: (string | number | boolean)[][]
): string[] {
return [...new Set(arrays.flatMap((arr) => arr.map((v) => v.toString())))];
}
function isEligibleFilterVariable(
variable: IDashboardVariable,
currentVariableId: string,
): boolean {
if (variable.id === currentVariableId) {
return false;
}
if (variable.type !== 'DYNAMIC') {
return false;
}
if (!variable.dynamicVariablesAttribute) {
return false;
}
if (!variable.selectedValue || isEmpty(variable.selectedValue)) {
return false;
}
return !(variable.showALLOption && variable.allSelected);
}
function formatQueryValue(val: string): string {
const numValue = Number(val);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter query string from sibling dynamic variables' selected values.
* e.g. `k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'`
*/
export function buildExistingDynamicVariableQuery(
existingVariables: IDashboardVariables | null,
currentVariableId: string,
hasDynamicAttribute: boolean,
): string {
if (!existingVariables || !hasDynamicAttribute) {
return '';
}
const queryParts: string[] = [];
for (const variable of Object.values(existingVariables)) {
// Skip the current variable being processed
if (!isEligibleFilterVariable(variable, currentVariableId)) {
continue;
}
const rawValues = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = rawValues
.filter(
(val): val is string | number | boolean =>
val !== null && val !== undefined && val !== '',
)
.map((val) => val.toString());
if (validValues.length > 0 && variable.dynamicVariablesAttribute) {
queryParts.push(
buildQueryPart(variable.dynamicVariablesAttribute, validValues),
);
}
}
return queryParts.join(' AND ');
}
function isVariableInActiveFetchState(state: string | undefined): boolean {
return state === 'loading' || state === 'revalidating';
}
/**
* Completes or fails a variable's fetch state machine transition.
* No-ops if the variable is not currently in an active fetch state.
*/
export function settleVariableFetch(
name: string | undefined,
outcome: 'complete' | 'failure',
): void {
if (!name) {
return;
}
const currentState = variableFetchStore.getSnapshot().states[name];
if (!isVariableInActiveFetchState(currentState)) {
return;
}
if (outcome === 'complete') {
onVariableFetchComplete(name);
} else {
onVariableFetchFailure(name);
}
}
export function extractErrorMessage(
error: { message?: string } | null,
): string {
if (!error) {
return SOMETHING_WENT_WRONG;
}
return (
error.message ||
'Please make sure configuration is valid and you have required setup and permissions'
);
}

View File

@@ -1,31 +1,4 @@
jest.mock('providers/Dashboard/store/variableFetchStore', () => ({
variableFetchStore: {
getSnapshot: jest.fn(),
},
onVariableFetchComplete: jest.fn(),
onVariableFetchFailure: jest.fn(),
}));
import {
onVariableFetchComplete,
onVariableFetchFailure,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
areArraysEqual,
buildExistingDynamicVariableQuery,
extractErrorMessage,
mergeUniqueStrings,
onUpdateVariableNode,
settleVariableFetch,
VariableGraph,
} from './util';
// ────────────────────────────────────────────────────────────────
// Existing tests
// ────────────────────────────────────────────────────────────────
import { areArraysEqual, onUpdateVariableNode, VariableGraph } from './util';
describe('areArraysEqual', () => {
it('should return true for equal arrays with same order', () => {
@@ -176,348 +149,3 @@ describe('onUpdateVariableNode', () => {
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
});
// ────────────────────────────────────────────────────────────────
// New tests for functions added in recent commits
// ────────────────────────────────────────────────────────────────
function makeDynamicVar(
overrides: Partial<IDashboardVariable> & { id: string },
): IDashboardVariable {
return {
name: overrides.id,
description: '',
type: 'DYNAMIC',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
allSelected: false,
dynamicVariablesAttribute: 'attr',
selectedValue: 'some-value',
...overrides,
} as IDashboardVariable;
}
describe('mergeUniqueStrings', () => {
it('should merge two arrays and deduplicate', () => {
expect(mergeUniqueStrings(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('should convert numbers and booleans to strings', () => {
expect(mergeUniqueStrings([1, true, 'hello'], [2, false])).toEqual([
'1',
'true',
'hello',
'2',
'false',
]);
});
it('should deduplicate when number and its string form both appear', () => {
expect(mergeUniqueStrings([42], ['42'])).toEqual(['42']);
});
it('should handle a single array', () => {
expect(mergeUniqueStrings(['x', 'y', 'x'])).toEqual(['x', 'y']);
});
it('should handle three or more arrays', () => {
expect(mergeUniqueStrings(['a'], ['b'], ['c'], ['a', 'c'])).toEqual([
'a',
'b',
'c',
]);
});
it('should return empty array when no arrays are provided', () => {
expect(mergeUniqueStrings()).toEqual([]);
});
it('should return empty array when all input arrays are empty', () => {
expect(mergeUniqueStrings([], [], [])).toEqual([]);
});
it('should preserve order of first occurrence', () => {
expect(mergeUniqueStrings(['c', 'a'], ['b', 'a'])).toEqual(['c', 'a', 'b']);
});
});
describe('buildExistingDynamicVariableQuery', () => {
// --- Guard clauses ---
it('should return empty string when existingVariables is null', () => {
expect(buildExistingDynamicVariableQuery(null, 'v1', true)).toBe('');
});
it('should return empty string when hasDynamicAttribute is false', () => {
const variables = { v2: makeDynamicVar({ id: 'v2' }) };
expect(buildExistingDynamicVariableQuery(variables, 'v1', false)).toBe('');
});
// --- Eligibility filtering ---
it('should skip the current variable (same id)', () => {
const variables = {
v1: makeDynamicVar({
id: 'v1',
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip non-DYNAMIC variables', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables without dynamicVariablesAttribute', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: undefined,
selectedValue: 'val',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with null selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: null }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with empty string selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: '' }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with empty array selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: [] }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables where showALLOption and allSelected are both true', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
showALLOption: true,
allSelected: true,
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should include variable with showALLOption true but allSelected false', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
showALLOption: true,
allSelected: false,
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"ns = 'prod'",
);
});
// --- Value formatting ---
it('should quote string values in the query', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: 'zeus',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name = 'zeus'",
);
});
it('should leave numeric values unquoted', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'http.status_code',
selectedValue: '200',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
'http.status_code = 200',
);
});
it('should escape single quotes in string values', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'user.name',
selectedValue: "O'Brien",
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"user.name = 'O\\'Brien'",
);
});
it('should build an IN clause for array selectedValue with multiple items', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: ['zeus', 'gene'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name IN ['zeus', 'gene']",
);
});
it('should handle mix of numeric and string values in IN clause', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'http.status_code',
selectedValue: ['200', 'unknown'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"http.status_code IN [200, 'unknown']",
);
});
it('should filter out empty string values from array', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'region',
selectedValue: ['us-east', '', 'eu-west'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"region IN ['us-east', 'eu-west']",
);
});
// --- Multiple siblings ---
it('should join multiple sibling variables with AND', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: ['zeus', 'gene'],
}),
v3: makeDynamicVar({
id: 'v3',
dynamicVariablesAttribute: 'doc_op_type',
selectedValue: 'test',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'",
);
});
it('should return empty string when no variables are eligible', () => {
const variables = {
v1: makeDynamicVar({ id: 'v1' }), // same as current — skipped
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }), // not DYNAMIC
v3: makeDynamicVar({ id: 'v3', selectedValue: null }), // no value
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
});
describe('settleVariableFetch', () => {
const mockGetSnapshot = variableFetchStore.getSnapshot as jest.Mock;
const mockComplete = onVariableFetchComplete as jest.Mock;
const mockFailure = onVariableFetchFailure as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
});
it('should no-op when name is undefined', () => {
settleVariableFetch(undefined, 'complete');
expect(mockGetSnapshot).not.toHaveBeenCalled();
expect(mockComplete).not.toHaveBeenCalled();
expect(mockFailure).not.toHaveBeenCalled();
});
it.each(['idle', 'waiting', 'error', undefined] as const)(
'should no-op when variable state is %s',
(state) => {
mockGetSnapshot.mockReturnValue({ states: { myVar: state } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).not.toHaveBeenCalled();
expect(mockFailure).not.toHaveBeenCalled();
},
);
it('should call onVariableFetchComplete when state is loading and outcome is complete', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).toHaveBeenCalledWith('myVar');
expect(mockFailure).not.toHaveBeenCalled();
});
it('should call onVariableFetchComplete when state is revalidating and outcome is complete', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).toHaveBeenCalledWith('myVar');
expect(mockFailure).not.toHaveBeenCalled();
});
it('should call onVariableFetchFailure when state is loading and outcome is failure', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
settleVariableFetch('myVar', 'failure');
expect(mockFailure).toHaveBeenCalledWith('myVar');
expect(mockComplete).not.toHaveBeenCalled();
});
it('should call onVariableFetchFailure when state is revalidating and outcome is failure', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
settleVariableFetch('myVar', 'failure');
expect(mockFailure).toHaveBeenCalledWith('myVar');
expect(mockComplete).not.toHaveBeenCalled();
});
});
describe('extractErrorMessage', () => {
const FALLBACK_MESSAGE =
'Please make sure configuration is valid and you have required setup and permissions';
it('should return SOMETHING_WENT_WRONG when error is null', () => {
expect(extractErrorMessage(null)).toBe('Something went wrong');
});
it('should return the error message when it exists', () => {
expect(extractErrorMessage({ message: 'Query timeout' })).toBe(
'Query timeout',
);
});
it('should return fallback when error object has no message property', () => {
expect(extractErrorMessage({})).toBe(FALLBACK_MESSAGE);
});
it('should return fallback when error.message is empty string', () => {
expect(extractErrorMessage({ message: '' })).toBe(FALLBACK_MESSAGE);
});
it('should return fallback when error.message is undefined', () => {
expect(extractErrorMessage({ message: undefined })).toBe(FALLBACK_MESSAGE);
});
});

View File

@@ -1,11 +1,17 @@
import { VariableItemProps } from '../VariableItem';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface VariableSelectStrategy {
handleChange(params: {
value: string | string[];
variableData: VariableItemProps['variableData'];
onValueUpdate: VariableItemProps['onValueUpdate'];
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
allAvailableOptionStrings: string[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}): void;
}

View File

@@ -17,19 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
@@ -108,7 +95,7 @@ describe('Dynamic Variable Default Behavior', () => {
}
}
if (queryFn) {
queryFn({ signal: undefined });
queryFn();
}
}
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
@@ -247,7 +234,6 @@ describe('Dynamic Variable Default Behavior', () => {
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});
@@ -501,7 +487,6 @@ describe('Dynamic Variable Default Behavior', () => {
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});

View File

@@ -49,11 +49,15 @@ const mockDashboard = {
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
variablesToGetUpdated: ['env'], // Stable initial value
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
}),
}));

View File

@@ -1,71 +0,0 @@
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-300);
}
.dashboard-icon-image {
height: 14px;
width: 14px;
}
}
.lightMode {
.dashboard-breadcrumbs {
.dashboard-btn {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Base64Icons } from '../../DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard, listSortOrder } = useDashboard();
const selectedData = selectedDashboard
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
}
: ({} as DashboardData);
const { title = '', image = Base64Icons[0] } = selectedData || {};
const goToListPage = useCallback(() => {
const urlParams = new URLSearchParams();
urlParams.set('columnKey', listSortOrder.columnKey as string);
urlParams.set('order', listSortOrder.order as string);
urlParams.set('page', listSortOrder.pagination as string);
urlParams.set('search', listSortOrder.search as string);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
safeNavigate(generatedUrl);
}, [listSortOrder, safeNavigate]);
return (
<div className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={goToListPage}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img src={image} alt="dashboard-icon" className="dashboard-icon-image" />
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -1,15 +0,0 @@
.dashboard-header {
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}
.lightMode {
.dashboard-header {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,17 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import './DashboardHeader.styles.scss';
function DashboardHeader(): JSX.Element {
return (
<div className="dashboard-header">
<DashboardBreadcrumbs />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -1,45 +0,0 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import BarChartTooltip from 'lib/uPlotV2/components/Tooltip/BarChartTooltip';
import {
BarTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const { children, isStackedBarChart, config, data, ...rest } = props;
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
const tooltipProps: BarTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
};
return <BarChartTooltip {...tooltipProps} />;
},
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
);
return (
<ChartWrapper
{...rest}
config={config}
data={chartData}
renderTooltip={renderTooltip}
>
{children}
</ChartWrapper>
);
}

View File

@@ -1,109 +0,0 @@
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function ChartWrapper({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
showTooltip = true,
showLegend = true,
canPinTooltip = false,
syncMode,
syncKey,
onDestroy = noop,
children,
layoutChildren,
renderTooltip,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
return null;
}
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[config, legendConfig.position, showLegend],
);
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (renderTooltip) {
return renderTooltip(args);
}
return null;
},
[renderTooltip],
);
return (
<PlotContextProvider>
<ChartLayout
showLegend={showLegend}
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{showTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={renderTooltipCallback}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -1,44 +0,0 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
import {
HistogramTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { HistogramChartProps } from '../types';
export default function Histogram(props: HistogramChartProps): JSX.Element {
const {
children,
renderTooltip: customRenderTooltip,
isQueriesMerged,
...rest
} = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper
showLegend={!isQueriesMerged}
{...rest}
renderTooltip={renderTooltip}
>
{children}
</ChartWrapper>
);
}

View File

@@ -1,35 +1,104 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
import {
TimeSeriesTooltipProps,
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import _noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { TimeSeriesChartProps } from '../types';
import { ChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <TimeSeriesTooltip {...tooltipProps} />;
export default function TimeSeries({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
disableTooltip = false,
canPinTooltip = false,
timezone,
yAxisUnit,
decimalPrecision,
syncMode,
syncKey,
onDestroy = _noop,
children,
layoutChildren,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[config, legendConfig.position],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
{children}
</ChartWrapper>
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{!disableTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={(props: TooltipRenderArgs): React.ReactNode => (
<Tooltip
{...props}
timezone={timezone}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
/>
)}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -1,47 +1,29 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { LegendConfig } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
height: number;
showTooltip?: boolean;
showLegend?: boolean;
disableTooltip?: boolean;
timezone: string;
syncMode?: DashboardCursorSync;
syncKey?: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {
interface TimeSeriesChartProps extends BaseChartProps {
config: UPlotConfigBuilder;
data: uPlot.AlignedData;
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
data: uPlot.AlignedData;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
isStackedBarChart?: boolean;
}
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
export type ChartProps = TimeSeriesChartProps;

View File

@@ -1,117 +0,0 @@
import { AlignedData } from 'uplot';
import { getInitialStackedBands, stack } from '../stackUtils';
describe('stackUtils', () => {
describe('stack', () => {
const neverOmit = (): boolean => false;
it('preserves time axis as first row', () => {
const data: AlignedData = [
[100, 200, 300],
[1, 2, 3],
[4, 5, 6],
];
const { data: result } = stack(data, neverOmit);
expect(result[0]).toEqual([100, 200, 300]);
});
it('stacks value series cumulatively (last = raw, first = total)', () => {
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
const data: AlignedData = [
[0, 1, 2],
[1, 2, 3], // series 1
[4, 5, 6], // series 2
[7, 8, 9], // series 3
];
const { data: result } = stack(data, neverOmit);
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
expect(result[1]).toEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
expect(result[2]).toEqual([11, 13, 15]); // 4+7, 5+8, 6+9
expect(result[3]).toEqual([7, 8, 9]);
});
it('treats null values as 0 when stacking', () => {
const data: AlignedData = [
[0, 1],
[1, null],
[null, 10],
];
const { data: result } = stack(data, neverOmit);
expect(result[1]).toEqual([1, 10]); // total
expect(result[2]).toEqual([0, 10]); // last series with null→0
});
it('copies omitted series as-is without accumulating', () => {
// Omit series 2 (index 2); series 1 and 3 are stacked.
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[100, 200], // series 2 - omitted
[1, 2], // series 3
];
const omitSeries2 = (i: number): boolean => i === 2;
const { data: result } = stack(data, omitSeries2);
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
expect(result[1]).toEqual([11, 22]); // 10+1, 20+2
expect(result[2]).toEqual([100, 200]); // copied, not stacked
expect(result[3]).toEqual([1, 2]);
});
it('returns bands between consecutive visible series when none omitted', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
[3, 4],
[5, 6],
];
const { bands } = stack(data, neverOmit);
expect(bands).toEqual([{ series: [1, 2] }, { series: [2, 3] }]);
});
it('returns bands only between visible series when some are omitted', () => {
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
const data: AlignedData = [[0], [1], [2], [3], [4]];
const omitSeries2 = (i: number): boolean => i === 2;
const { bands } = stack(data, omitSeries2);
expect(bands).toEqual([{ series: [1, 3] }, { series: [3, 4] }]);
});
it('returns empty bands when only one value series', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
];
const { bands } = stack(data, neverOmit);
expect(bands).toEqual([]);
});
});
describe('getInitialStackedBands', () => {
it('returns one band between each consecutive pair for seriesCount 3', () => {
expect(getInitialStackedBands(3)).toEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('returns empty array for seriesCount 0 or 1', () => {
expect(getInitialStackedBands(0)).toEqual([]);
expect(getInitialStackedBands(1)).toEqual([]);
});
it('returns single band for seriesCount 2', () => {
expect(getInitialStackedBands(2)).toEqual([{ series: [1, 2] }]);
});
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
const bands = getInitialStackedBands(5);
expect(bands).toEqual([
{ series: [1, 2] },
{ series: [2, 3] },
{ series: [3, 4] },
{ series: [4, 5] },
]);
});
});
});

View File

@@ -1,116 +0,0 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -1,116 +0,0 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -1,313 +0,0 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return (c as unknown) as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return ({
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown) as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toEqual([0, 1]);
expect(result.current[1]).toEqual([5, 7]); // stacked
expect(result.current[2]).toEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toEqual([0, 1, 2]);
expect(result.current[1]).toEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -1,69 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
jest.mock('providers/Dashboard/Dashboard');
type MockHTMLElement = {
scrollIntoView: jest.Mock;
focus: jest.Mock;
};
function createMockElement(): MockHTMLElement {
return {
scrollIntoView: jest.fn(),
focus: jest.fn(),
};
}
describe('useScrollWidgetIntoView', () => {
const mockedUseDashboard = useDashboard as jest.MockedFunction<
typeof useDashboard
>;
beforeEach(() => {
jest.clearAllMocks();
});
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
const setToScrollWidgetId = jest.fn();
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'widget-id',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
expect(mockElement.scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
expect(mockElement.focus).toHaveBeenCalled();
expect(setToScrollWidgetId).toHaveBeenCalledWith('');
});
it('does nothing when toScrollWidgetId does not match widget id', () => {
const setToScrollWidgetId = jest.fn();
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'other-widget',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
expect(mockElement.scrollIntoView).not.toHaveBeenCalled();
expect(mockElement.focus).not.toHaveBeenCalled();
expect(setToScrollWidgetId).not.toHaveBeenCalled();
});
});

View File

@@ -1,125 +0,0 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
applyStackingToChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
applyStackingToChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
if (!has(opts, 'focus')) {
applyStackingToChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseBarChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
*/
export function useBarChartStacking({
data,
isStackedBarChart = false,
config,
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
return undefined;
}
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return chartData;
}

View File

@@ -1,26 +0,0 @@
import { RefObject, useEffect } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
/**
* Scrolls the given widget container into view when the dashboard
* requests it via `toScrollWidgetId`.
*
* Intended for use in panel components that render a single widget.
*/
export function useScrollWidgetIntoView<T extends HTMLElement>(
widgetId: string,
widgetContainerRef: RefObject<T>,
): void {
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
useEffect(() => {
if (toScrollWidgetId === widgetId) {
widgetContainerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
widgetContainerRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widgetId, widgetContainerRef]);
}

View File

@@ -1,14 +1,12 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import './ChartLayout.styles.scss';
export interface ChartLayoutProps {
showLegend?: boolean;
legendComponent: (legendPerSet: number) => React.ReactNode;
children: (props: {
chartWidth: number;
@@ -22,7 +20,6 @@ export interface ChartLayoutProps {
config: UPlotConfigBuilder;
}
export default function ChartLayout({
showLegend = true,
legendComponent,
children,
layoutChildren,
@@ -33,15 +30,6 @@ export default function ChartLayout({
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
if (!showLegend) {
return {
width: containerWidth,
height: containerHeight,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: MAX_LEGEND_WIDTH,
};
}
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
@@ -54,7 +42,7 @@ export default function ChartLayout({
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig, showLegend],
[containerWidth, containerHeight, legendConfig],
);
return (
@@ -72,17 +60,15 @@ export default function ChartLayout({
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
{showLegend && (
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
)}
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
</div>
{layoutChildren}
</div>

View File

@@ -1,150 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
panelMode,
queryResponse,
widget,
onDragSelect,
isFullViewMode,
onToggleModelHandler,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useScrollWidgetIntoView(widget.id, graphRef);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [queryResponse]);
const {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
} = usePanelContextMenu({
widget,
queryResponse,
});
const config = useMemo(() => {
return prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery: widget.query,
onClick: clickHandlerWithContextMenu,
onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
timezone,
panelMode,
minTimeScale: minTimeScale,
maxTimeScale: maxTimeScale,
});
}, [
widget,
isDarkMode,
queryResponse?.data?.payload,
clickHandlerWithContextMenu,
onDragSelect,
minTimeScale,
maxTimeScale,
timezone,
panelMode,
]);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {
if (!isFullViewMode) {
return null;
}
return (
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={widget.yAxisUnit}
onCancel={onToggleModelHandler}
/>
);
}, [
isFullViewMode,
config,
chartData,
widget.yAxisUnit,
onToggleModelHandler,
]);
const onPlotDestroy = useCallback(() => {
uPlotRef.current = null;
}, []);
const onPlotRef = useCallback((plot: uPlot | null): void => {
uPlotRef.current = plot;
}, []);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
</BarChart>
)}
</div>
);
}
export default BarPanel;

Some files were not shown because too many files have changed in this diff Show More