mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-19 15:32:30 +00:00
Compare commits
27 Commits
fix/root-u
...
SIG_3786_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e482030ecd | ||
|
|
5cc562ba35 | ||
|
|
22099962be | ||
|
|
2559b52bb1 | ||
|
|
7523596043 | ||
|
|
9f4d9eef46 | ||
|
|
d9cddbfb42 | ||
|
|
0828c13f7c | ||
|
|
2c558b9bf7 | ||
|
|
48b588fe48 | ||
|
|
5c86b80682 | ||
|
|
75512a81c6 | ||
|
|
6aaea79b73 | ||
|
|
04643264ff | ||
|
|
3aa0d8a7fd | ||
|
|
7f1d350ffe | ||
|
|
1d3134959d | ||
|
|
b86bd24dd9 | ||
|
|
4c49d45cbf | ||
|
|
9b3d3453b1 | ||
|
|
9d981d8a13 | ||
|
|
6de4520a95 | ||
|
|
f566909320 | ||
|
|
aa39db8ac2 | ||
|
|
eb2c6b78c8 | ||
|
|
2d2d0c3d9f | ||
|
|
8a4544cbac |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1,11 @@
|
||||
|
||||
node_modules
|
||||
|
||||
# editor
|
||||
.vscode
|
||||
!.vscode/settings.json
|
||||
.zed
|
||||
.idea
|
||||
|
||||
deploy/docker/environment_tiny/common_test
|
||||
frontend/node_modules
|
||||
@@ -31,8 +34,6 @@ frontend/yarn-debug.log*
|
||||
frontend/yarn-error.log*
|
||||
frontend/src/constants/env.ts
|
||||
|
||||
.idea
|
||||
|
||||
**/build
|
||||
**/storage
|
||||
**/locust-scripts/__pycache__/
|
||||
@@ -229,5 +230,3 @@ cython_debug/
|
||||
pyrightconfig.json
|
||||
|
||||
|
||||
# cursor files
|
||||
frontend/.cursor/
|
||||
|
||||
@@ -176,25 +176,6 @@ 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
|
||||
|
||||
28
README.md
28
README.md
@@ -221,34 +221,6 @@ 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 />
|
||||
|
||||
|
||||
|
||||
@@ -187,25 +187,6 @@ 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 />
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -294,7 +294,6 @@ flagger:
|
||||
config:
|
||||
boolean:
|
||||
use_span_metrics: true
|
||||
interpolation_enabled: false
|
||||
kafka_span_eval: false
|
||||
string:
|
||||
float:
|
||||
@@ -309,3 +308,14 @@ 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
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.111.0
|
||||
image: signoz/signoz:v0.112.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.111.0
|
||||
image: signoz/signoz:v0.112.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.111.0}
|
||||
image: signoz/signoz:${VERSION:-v0.112.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.111.0}
|
||||
image: signoz/signoz:${VERSION:-v0.112.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
3848
docs/api/openapi.yml
3848
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -45,7 +46,7 @@ type APIHandler struct {
|
||||
}
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
RuleManager: opts.RulesManager,
|
||||
@@ -58,7 +59,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
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
|
||||
@@ -106,7 +107,10 @@ 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.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
|
||||
router.Handle("/api/v5/query_range", handler.New(
|
||||
am.ViewAccess(ah.queryRangeV5),
|
||||
querierAPI.QueryRangeV5OpenAPIDef,
|
||||
)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
GlobalConfig: config.Global,
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
74
frontend/.cursor/rules/testing-conventions.mdc
Normal file
74
frontend/.cursor/rules/testing-conventions.mdc
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
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
|
||||
54
frontend/.cursor/rules/testing-mock-strategy.mdc
Normal file
54
frontend/.cursor/rules/testing-mock-strategy.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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) }));
|
||||
```
|
||||
54
frontend/.cursor/rules/testing-type-safety.mdc
Normal file
54
frontend/.cursor/rules/testing-type-safety.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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
|
||||
@@ -1,484 +0,0 @@
|
||||
# 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**: 3–5 focused tests per file
|
||||
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
|
||||
- **No Any**: Enforce type safety
|
||||
|
||||
# Example Test
|
||||
```ts
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import MyComponent from '../MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders and interacts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
|
||||
);
|
||||
|
||||
render(<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)
|
||||
}));
|
||||
```
|
||||
@@ -55,8 +55,10 @@
|
||||
"@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",
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"pipeline": "Pipeline",
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics"
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"pipeline": "Pipeline",
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics"
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
}
|
||||
|
||||
@@ -73,5 +73,6 @@
|
||||
"API_MONITORING": "SigNoz | External APIs",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles"
|
||||
}
|
||||
|
||||
@@ -20,17 +20,20 @@ import { useMutation, useQuery } from 'react-query';
|
||||
import { GeneratedAPIInstance } from '../../../index';
|
||||
import type {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAlertsPathParameters,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricAttributesPathParameters,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsPathParameters,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricHighlightsPathParameters,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricMetadataPathParameters,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
MetricsexplorertypesMetricAttributesRequestDTO,
|
||||
ListMetrics200,
|
||||
ListMetricsParams,
|
||||
MetricsexplorertypesStatsRequestDTO,
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
@@ -43,30 +46,128 @@ type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoint returns associated alerts for a specified metric
|
||||
* @summary Get metric alerts
|
||||
* This endpoint returns a list of distinct metric names within the specified time range
|
||||
* @summary List metric names
|
||||
*/
|
||||
export const getMetricAlerts = (
|
||||
params: GetMetricAlertsParams,
|
||||
export const listMetrics = (
|
||||
params?: ListMetricsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricAlerts200>({
|
||||
url: `/api/v2/metric/alerts`,
|
||||
return GeneratedAPIInstance<ListMetrics200>({
|
||||
url: `/api/v2/metrics`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
|
||||
return ['getMetricAlerts', ...(params ? [params] : [])] as const;
|
||||
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 getGetMetricAlertsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -77,13 +178,19 @@ export const getGetMetricAlertsQueryOptions = <
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
|
||||
signal,
|
||||
}) => getMetricAlerts(params, signal);
|
||||
}) => getMetricAlerts({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -103,7 +210,7 @@ export function useGetMetricAlerts<
|
||||
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricAlerts>>,
|
||||
@@ -112,7 +219,7 @@ export function useGetMetricAlerts<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -128,11 +235,126 @@ export function useGetMetricAlerts<
|
||||
*/
|
||||
export const invalidateGetMetricAlerts = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricAlertsParams,
|
||||
{ metricName }: GetMetricAlertsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricAlertsQueryKey(params) },
|
||||
{ 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) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -144,28 +366,27 @@ export const invalidateGetMetricAlerts = async (
|
||||
* @summary Get metric dashboards
|
||||
*/
|
||||
export const getMetricDashboards = (
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboards200>({
|
||||
url: `/api/v2/metric/dashboards`,
|
||||
url: `/api/v2/metrics/${metricName}/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryKey = (
|
||||
params?: GetMetricDashboardsParams,
|
||||
) => {
|
||||
return ['getMetricDashboards', ...(params ? [params] : [])] as const;
|
||||
export const getGetMetricDashboardsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricDashboardsPathParameters) => {
|
||||
return ['getMetricDashboards'] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -177,13 +398,18 @@ export const getGetMetricDashboardsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>
|
||||
> = ({ signal }) => getMetricDashboards(params, signal);
|
||||
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -203,7 +429,7 @@ export function useGetMetricDashboards<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboards>>,
|
||||
@@ -212,7 +438,10 @@ export function useGetMetricDashboards<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricDashboardsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -228,11 +457,11 @@ export function useGetMetricDashboards<
|
||||
*/
|
||||
export const invalidateGetMetricDashboards = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsParams,
|
||||
{ metricName }: GetMetricDashboardsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsQueryKey(params) },
|
||||
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -244,28 +473,27 @@ export const invalidateGetMetricDashboards = async (
|
||||
* @summary Get metric highlights
|
||||
*/
|
||||
export const getMetricHighlights = (
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricHighlights200>({
|
||||
url: `/api/v2/metric/highlights`,
|
||||
url: `/api/v2/metrics/${metricName}/highlights`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryKey = (
|
||||
params?: GetMetricHighlightsParams,
|
||||
) => {
|
||||
return ['getMetricHighlights', ...(params ? [params] : [])] as const;
|
||||
export const getGetMetricHighlightsQueryKey = ({
|
||||
metricName,
|
||||
}: GetMetricHighlightsPathParameters) => {
|
||||
return ['getMetricHighlights'] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricHighlightsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -277,13 +505,18 @@ export const getGetMetricHighlightsQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
|
||||
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>
|
||||
> = ({ signal }) => getMetricHighlights(params, signal);
|
||||
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!metricName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError,
|
||||
TData
|
||||
@@ -303,7 +536,7 @@ export function useGetMetricHighlights<
|
||||
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricHighlights>>,
|
||||
@@ -312,7 +545,10 @@ export function useGetMetricHighlights<
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
|
||||
const queryOptions = getGetMetricHighlightsQueryOptions(
|
||||
{ metricName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
@@ -328,11 +564,115 @@ export function useGetMetricHighlights<
|
||||
*/
|
||||
export const invalidateGetMetricHighlights = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricHighlightsParams,
|
||||
{ metricName }: GetMetricHighlightsPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricHighlightsQueryKey(params) },
|
||||
{ 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 }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -438,189 +778,6 @@ 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
|
||||
|
||||
107
frontend/src/api/generated/services/query/index.ts
Normal file
107
frontend/src/api/generated/services/query/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* ! 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
@@ -11,13 +11,10 @@ export const getMetricMetadata = async (
|
||||
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
|
||||
try {
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const response = await axios.get(
|
||||
`/metrics/metadata?metricName=${encodedMetricName}`,
|
||||
{
|
||||
signal,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
2
frontend/src/auto-import-registry.d.ts
vendored
2
frontend/src/auto-import-registry.d.ts
vendored
@@ -21,7 +21,9 @@ 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';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Virtuoso, VirtuosoHandle } 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';
|
||||
@@ -10,6 +11,8 @@ 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';
|
||||
@@ -28,6 +31,15 @@ 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,
|
||||
@@ -72,29 +84,40 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
setIsPaginating(false);
|
||||
}, [data, setIsPaginating]);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: 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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
(_: 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],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback(
|
||||
@@ -118,6 +141,7 @@ 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}
|
||||
@@ -139,7 +163,24 @@ 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">{renderContent}</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,9 @@ 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'>;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
}
|
||||
|
||||
.log-detail-drawer__title-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -40,6 +42,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -66,6 +69,10 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-detail-drawer__content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.log-detail-drawer__log {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -183,9 +190,115 @@
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
.log-detail-drawer__footer-hint {
|
||||
position: sticky;
|
||||
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);
|
||||
@@ -252,4 +365,33 @@
|
||||
color: var(--text-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.log-detail-drawer__footer-hint {
|
||||
position: sticky;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useCopyToClipboard, useLocation } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
@@ -32,8 +32,12 @@ 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,
|
||||
@@ -60,6 +64,9 @@ function LogDetailInner({
|
||||
isListViewPanel = false,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
logs,
|
||||
onNavigateLog,
|
||||
onScrollToLog,
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
@@ -74,6 +81,78 @@ 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;
|
||||
@@ -227,32 +306,87 @@ 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%"
|
||||
maskStyle={{ background: 'none' }}
|
||||
mask={false}
|
||||
maskClosable={false}
|
||||
title={
|
||||
<div className="log-detail-drawer__title">
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<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>
|
||||
{showOpenInExplorerBtn && (
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<Button
|
||||
className="open-in-explorer-btn"
|
||||
icon={<Compass size={16} />}
|
||||
onClick={handleOpenInExplorer}
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<div className="log-arrows">
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? '' : 'Move to previous log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
<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>
|
||||
</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={{
|
||||
@@ -263,138 +397,164 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<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__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-overflow-shadow"> </div>
|
||||
</div>
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<div className="tabs-and-search">
|
||||
<Radio.Group
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
>
|
||||
<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>
|
||||
<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="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={handleFilterVisible}
|
||||
icon={<Copy size={16} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
|
||||
|
||||
{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.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>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ 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
|
||||
@@ -104,12 +102,17 @@ function LogSelectedField({
|
||||
type ListLogViewProps = {
|
||||
logData: ILog;
|
||||
selectedFields: IField[];
|
||||
onSetActiveLog: (log: ILog) => void;
|
||||
onSetActiveLog: (
|
||||
log: ILog,
|
||||
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
|
||||
) => void;
|
||||
onAddToQuery: AddToQueryHOCProps['onAddToQuery'];
|
||||
activeLog?: ILog | null;
|
||||
linesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
isActiveLog?: boolean;
|
||||
onClearActiveLog?: () => void;
|
||||
};
|
||||
|
||||
function ListLogView({
|
||||
@@ -120,7 +123,8 @@ function ListLogView({
|
||||
activeLog,
|
||||
linesPerRow,
|
||||
fontSize,
|
||||
handleChangeSelectedView,
|
||||
isActiveLog,
|
||||
onClearActiveLog,
|
||||
}: ListLogViewProps): JSX.Element {
|
||||
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
|
||||
|
||||
@@ -129,35 +133,24 @@ function ListLogView({
|
||||
);
|
||||
const isReadOnlyLog = !isLogsExplorerPage;
|
||||
|
||||
const {
|
||||
activeLog: activeContextLog,
|
||||
onAddToQuery: handleAddToQuery,
|
||||
onSetActiveLog: handleSetActiveContextLog,
|
||||
onClearActiveLog: handleClearActiveContextLog,
|
||||
} = useActiveLog();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handlerClearActiveContextLog = useCallback(
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleClearActiveContextLog();
|
||||
},
|
||||
[handleClearActiveContextLog],
|
||||
);
|
||||
|
||||
const handleDetailedView = useCallback(() => {
|
||||
if (isActiveLog) {
|
||||
onClearActiveLog?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onSetActiveLog(logData);
|
||||
}, [logData, onSetActiveLog]);
|
||||
}, [logData, onSetActiveLog, isActiveLog, onClearActiveLog]);
|
||||
|
||||
const handleShowContext = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleSetActiveContextLog(logData);
|
||||
onSetActiveLog(logData, VIEW_TYPES.CONTEXT);
|
||||
},
|
||||
[logData, handleSetActiveContextLog],
|
||||
[logData, onSetActiveLog],
|
||||
);
|
||||
|
||||
const updatedSelecedFields = useMemo(
|
||||
@@ -186,11 +179,7 @@ function ListLogView({
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
$isActiveLog={
|
||||
isHighlighted ||
|
||||
activeLog?.id === logData.id ||
|
||||
activeContextLog?.id === logData.id
|
||||
}
|
||||
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
|
||||
$isDarkMode={isDarkMode}
|
||||
$logType={logType}
|
||||
onClick={handleDetailedView}
|
||||
@@ -251,15 +240,6 @@ function ListLogView({
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
{activeContextLog && (
|
||||
<LogDetail
|
||||
log={activeContextLog}
|
||||
onAddToQuery={handleAddToQuery}
|
||||
selectedTab={VIEW_TYPES.CONTEXT}
|
||||
onClose={handlerClearActiveContextLog}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
KeyboardEvent,
|
||||
memo,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DrawerProps, Tooltip } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
||||
import { Tooltip } from 'antd';
|
||||
import { VIEW_TYPES } 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';
|
||||
@@ -39,7 +35,8 @@ function RawLogView({
|
||||
selectedFields = [],
|
||||
fontSize,
|
||||
onLogClick,
|
||||
handleChangeSelectedView,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
}: RawLogViewProps): JSX.Element {
|
||||
const {
|
||||
isHighlighted: isUrlHighlighted,
|
||||
@@ -48,15 +45,6 @@ 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;
|
||||
|
||||
@@ -134,34 +122,24 @@ function RawLogView({
|
||||
// Use custom click handler if provided, otherwise use default behavior
|
||||
if (onLogClick) {
|
||||
onLogClick(data, event);
|
||||
} else {
|
||||
onSetActiveLog(data);
|
||||
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
||||
return;
|
||||
}
|
||||
if (isActiveLog) {
|
||||
onClearActiveLog?.();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[isReadOnly, data, onSetActiveLog, onLogClick],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
|
||||
(
|
||||
event: MouseEvent<Element, globalThis.MouseEvent> | KeyboardEvent<Element>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onClearActiveLog();
|
||||
setSelectedTab(undefined);
|
||||
onSetActiveLog?.(data);
|
||||
},
|
||||
[onClearActiveLog],
|
||||
[isReadOnly, onLogClick, isActiveLog, onSetActiveLog, data, onClearActiveLog],
|
||||
);
|
||||
|
||||
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// handleSetActiveContextLog(data);
|
||||
setSelectedTab(VIEW_TYPES.CONTEXT);
|
||||
onSetActiveLog(data);
|
||||
|
||||
onSetActiveLog?.(data, VIEW_TYPES.CONTEXT);
|
||||
},
|
||||
[data, onSetActiveLog],
|
||||
);
|
||||
@@ -181,7 +159,7 @@ function RawLogView({
|
||||
$isDarkMode={isDarkMode}
|
||||
$isReadOnly={isReadOnly}
|
||||
$isHightlightedLog={isUrlHighlighted}
|
||||
$isActiveLog={activeLog?.id === data.id || isActiveLog}
|
||||
$isActiveLog={isActiveLog}
|
||||
$isCustomHighlighted={isHighlighted}
|
||||
$logType={logType}
|
||||
fontSize={fontSize}
|
||||
@@ -218,17 +196,6 @@ function RawLogView({
|
||||
onLogCopy={onLogCopy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTab && (
|
||||
<LogDetail
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</RawLogViewContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,9 +45,6 @@ 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -16,6 +17,11 @@ 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 {
|
||||
|
||||
@@ -55,6 +55,7 @@ const ROUTES = {
|
||||
LOGS_INDEX_FIELDS: '/logs-explorer/index-fields',
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
||||
|
||||
@@ -14,6 +14,6 @@ export const VIEW_TYPES = {
|
||||
export const SPAN_ATTRIBUTES = {
|
||||
URL_PATH: 'http.url',
|
||||
RESPONSE_STATUS_CODE: 'response_status_code',
|
||||
SERVER_NAME: 'net.peer.name',
|
||||
SERVER_NAME: 'http_host',
|
||||
SERVER_PORT: 'net.peer.port',
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -215,7 +216,7 @@ describe('TopErrors', () => {
|
||||
value: 'true',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'net.peer.name' }),
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.SERVER_NAME }),
|
||||
op: '=',
|
||||
value: 'test-domain',
|
||||
}),
|
||||
|
||||
@@ -638,7 +638,7 @@ export const getEndPointsQueryPayload = (
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
@@ -685,7 +685,7 @@ export const getEndPointsQueryPayload = (
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
@@ -733,7 +733,7 @@ export const getEndPointsQueryPayload = (
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
@@ -780,7 +780,7 @@ export const getEndPointsQueryPayload = (
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
@@ -1302,7 +1302,7 @@ export const getTopErrorsCoRelationQueryFilters = (
|
||||
{
|
||||
id: 'e8a043b7',
|
||||
key: {
|
||||
key: 'net.peer.name',
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
@@ -2198,7 +2198,7 @@ export const getEndPointZeroStateQueryPayload = (
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
@@ -2793,7 +2793,7 @@ export const getStatusCodeBarChartWidgetData = (
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: 'tag',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
|
||||
@@ -9,74 +9,6 @@
|
||||
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;
|
||||
@@ -535,15 +467,6 @@
|
||||
.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 {
|
||||
|
||||
@@ -16,9 +16,7 @@ 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';
|
||||
@@ -27,7 +25,6 @@ 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,
|
||||
@@ -37,7 +34,6 @@ import {
|
||||
FolderKanban,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
X,
|
||||
@@ -51,6 +47,7 @@ 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';
|
||||
@@ -71,7 +68,6 @@ interface DashboardDescriptionProps {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { handle } = props;
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -80,7 +76,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
layouts,
|
||||
setLayouts,
|
||||
isDashboardLocked,
|
||||
listSortOrder,
|
||||
setSelectedDashboard,
|
||||
handleToggleDashboardSlider,
|
||||
setSelectedRowWidgetId,
|
||||
@@ -292,17 +287,6 @@ 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,
|
||||
@@ -351,32 +335,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<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>
|
||||
<DashboardHeader />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
}
|
||||
|
||||
.variable-select {
|
||||
.ant-select-selector {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
@@ -0,0 +1,15 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
@@ -23,6 +23,7 @@ export default function ChartWrapper({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
syncMode,
|
||||
syncKey,
|
||||
@@ -36,6 +37,9 @@ export default function ChartWrapper({
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
@@ -44,7 +48,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position],
|
||||
[config, legendConfig.position, showLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -60,6 +64,7 @@ export default function ChartWrapper({
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
showLegend={showLegend}
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
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 content = buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
uPlotInstance: props.uPlotInstance,
|
||||
yAxisUnit: rest.yAxisUnit ?? '',
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
});
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
content,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
showLegend={!isQueriesMerged}
|
||||
{...rest}
|
||||
renderTooltip={renderTooltip}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
@@ -17,6 +18,7 @@ interface BaseChartProps {
|
||||
interface UPlotBasedChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
data: uPlot.AlignedData;
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
@@ -26,14 +28,20 @@ interface UPlotBasedChartProps {
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type ChartProps = TimeSeriesChartProps | BarChartProps;
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
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;
|
||||
@@ -20,6 +22,7 @@ export interface ChartLayoutProps {
|
||||
config: UPlotConfigBuilder;
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
@@ -30,6 +33,15 @@ 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)
|
||||
@@ -42,7 +54,7 @@ export default function ChartLayout({
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -60,15 +72,17 @@ export default function ChartLayout({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{layoutChildren}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
@@ -27,7 +27,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
@@ -36,16 +35,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
@@ -54,6 +55,13 @@ export function prepareBarPanelConfig({
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}): UPlotConfigBuilder {
|
||||
const stepIntervals: ExecStats['stepIntervals'] = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
);
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
@@ -65,12 +73,7 @@ export function prepareBarPanelConfig({
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
focus: {
|
||||
prox: 1e3,
|
||||
},
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
if (widget.stackedBarChart) {
|
||||
@@ -78,12 +81,6 @@ export function prepareBarPanelConfig({
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
}
|
||||
|
||||
const stepIntervals: Record<string, number> = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
);
|
||||
|
||||
const seriesList: QueryData[] = apiResponse?.data?.result || [];
|
||||
seriesList.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useRef } 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 { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import {
|
||||
prepareHistogramPanelConfig,
|
||||
prepareHistogramPanelData,
|
||||
} from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
panelMode,
|
||||
queryResponse,
|
||||
widget,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
panelMode,
|
||||
});
|
||||
}, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!queryResponse?.data?.payload) {
|
||||
return [];
|
||||
}
|
||||
return prepareHistogramPanelData({
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
bucketWidth: widget?.bucketWidth,
|
||||
bucketCount: widget?.bucketCount,
|
||||
mergeAllActiveQueries: widget?.mergeAllActiveQueries,
|
||||
});
|
||||
}, [
|
||||
queryResponse?.data?.payload,
|
||||
widget?.bucketWidth,
|
||||
widget?.bucketCount,
|
||||
widget?.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
if (!isFullViewMode || widget.mergeAllActiveQueries) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onCancel={onToggleModelHandler}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isFullViewMode,
|
||||
config,
|
||||
chartData,
|
||||
widget.yAxisUnit,
|
||||
onToggleModelHandler,
|
||||
widget.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<Histogram
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
plotRef={(plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}}
|
||||
onDestroy={(): void => {
|
||||
uPlotRef.current = null;
|
||||
}}
|
||||
isQueriesMerged={widget.mergeAllActiveQueries}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
syncMode={DashboardCursorSync.Crosshair}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramPanel;
|
||||
@@ -0,0 +1,223 @@
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
MetricQueryRangeSuccessResponse,
|
||||
MetricRangePayloadProps,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import HistogramPanel from '../HistogramPanel';
|
||||
import { HistogramChartProps } from 'container/DashboardContainer/visualization/charts/types';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 800, height: 400 }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
// Provide a no-op provider component so AllTheProviders can render
|
||||
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<>{children}</>
|
||||
),
|
||||
// And mock the hook used by HistogramPanel
|
||||
useTimezone: jest.fn().mockReturnValue({
|
||||
timezone: { value: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView',
|
||||
() => ({
|
||||
useScrollWidgetIntoView: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/Histogram/Histogram',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (props: HistogramChartProps): JSX.Element => (
|
||||
<div data-testid="histogram-chart">
|
||||
<div data-testid="histogram-props">
|
||||
{JSON.stringify({
|
||||
legendPosition: props.legendConfig?.position,
|
||||
isQueriesMerged: props.isQueriesMerged,
|
||||
yAxisUnit: props.yAxisUnit,
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
})}
|
||||
</div>
|
||||
{props.layoutChildren}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/components/ChartManager/ChartManager',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="chart-manager">ChartManager</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function createQueryResponse(
|
||||
payloadOverrides: Partial<MetricRangePayloadProps> = {},
|
||||
): { data: { payload: MetricRangePayloadProps } } {
|
||||
const basePayload: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
legend: 'Series A',
|
||||
values: [
|
||||
[1, '10'],
|
||||
[2, '20'],
|
||||
],
|
||||
},
|
||||
],
|
||||
resultType: 'matrix',
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
resultType: 'matrix',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
data: {
|
||||
payload: {
|
||||
...basePayload,
|
||||
...payloadOverrides,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type WidgetLike = {
|
||||
id: string;
|
||||
yAxisUnit: string;
|
||||
decimalPrecision: number;
|
||||
legendPosition: LegendPosition;
|
||||
mergeAllActiveQueries: boolean;
|
||||
};
|
||||
|
||||
function createWidget(overrides: Partial<WidgetLike> = {}): WidgetLike {
|
||||
return {
|
||||
id: 'widget-id',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
legendPosition: LegendPosition.BOTTOM,
|
||||
mergeAllActiveQueries: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HistogramPanel', () => {
|
||||
it('renders Histogram when container has dimensions', () => {
|
||||
const widget = (createWidget() as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode={false}
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('histogram-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes legend position and other props to Histogram', () => {
|
||||
const widget = (createWidget({
|
||||
legendPosition: LegendPosition.RIGHT,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode={false}
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const propsJson = screen.getByTestId('histogram-props').textContent || '{}';
|
||||
const parsed = JSON.parse(propsJson);
|
||||
|
||||
expect(parsed.legendPosition).toBe(LegendPosition.RIGHT);
|
||||
expect(parsed.yAxisUnit).toBe('ms');
|
||||
expect(parsed.decimalPrecision).toBe(2);
|
||||
});
|
||||
|
||||
it('renders ChartManager in full view when queries are not merged', () => {
|
||||
const widget = (createWidget({
|
||||
mergeAllActiveQueries: false,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chart-manager')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render ChartManager when queries are merged', () => {
|
||||
const widget = (createWidget({
|
||||
mergeAllActiveQueries: true,
|
||||
}) as unknown) as Widgets;
|
||||
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
|
||||
MetricQueryRangeSuccessResponse,
|
||||
Error
|
||||
>;
|
||||
|
||||
render(
|
||||
<HistogramPanel
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={jest.fn()}
|
||||
onDragSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('chart-manager')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { histogramBucketSizes } from '@grafana/data';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { incrRoundDn, roundDecimals } from 'utils/round';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
import {
|
||||
buildHistogramBuckets,
|
||||
mergeAlignedDataTables,
|
||||
prependNullBinToFirstHistogramSeries,
|
||||
replaceUndefinedWithNullInAlignedData,
|
||||
} from '../utils/histogram';
|
||||
|
||||
export interface PrepareHistogramPanelDataParams {
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
bucketWidth?: number;
|
||||
bucketCount?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_OFFSET = 0;
|
||||
const HIST_SORT = (a: number, b: number): number => a - b;
|
||||
|
||||
function extractNumericValues(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
for (const item of result) {
|
||||
for (const [, valueStr] of item.values) {
|
||||
values.push(Number.parseFloat(valueStr) || 0);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeSmallestDelta(sortedValues: number[]): number {
|
||||
if (sortedValues.length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
let smallest = Infinity;
|
||||
for (let i = 1; i < sortedValues.length; i++) {
|
||||
const delta = sortedValues[i] - sortedValues[i - 1];
|
||||
if (delta > 0) {
|
||||
smallest = Math.min(smallest, delta);
|
||||
}
|
||||
}
|
||||
return smallest === Infinity ? 0 : smallest;
|
||||
}
|
||||
|
||||
function selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride,
|
||||
}: {
|
||||
range: number;
|
||||
bucketCount: number;
|
||||
smallestDelta: number;
|
||||
bucketWidthOverride?: number;
|
||||
}): number {
|
||||
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
|
||||
return bucketWidthOverride;
|
||||
}
|
||||
const targetSize = range / bucketCount;
|
||||
for (const candidate of histogramBucketSizes) {
|
||||
if (targetSize < candidate && candidate >= smallestDelta) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildFrames(
|
||||
result: MetricRangePayloadProps['data']['result'],
|
||||
mergeAllActiveQueries: boolean,
|
||||
): number[][] {
|
||||
const frames: number[][] = result.map((item) =>
|
||||
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
|
||||
);
|
||||
if (mergeAllActiveQueries && frames.length > 1) {
|
||||
const first = frames[0];
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
first.push(...frames[i]);
|
||||
frames[i] = [];
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelData({
|
||||
apiResponse,
|
||||
bucketWidth,
|
||||
bucketCount: bucketCountProp = DEFAULT_BUCKET_COUNT,
|
||||
mergeAllActiveQueries = false,
|
||||
}: PrepareHistogramPanelDataParams): AlignedData {
|
||||
const bucketCount = bucketCountProp ?? DEFAULT_BUCKET_COUNT;
|
||||
const result = apiResponse.data.result;
|
||||
|
||||
const seriesValues = extractNumericValues(result);
|
||||
if (seriesValues.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const sorted = [...seriesValues].sort((a, b) => a - b);
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const range = max - min;
|
||||
const smallestDelta = computeSmallestDelta(sorted);
|
||||
let bucketSize = selectBucketSize({
|
||||
range,
|
||||
bucketCount,
|
||||
smallestDelta,
|
||||
bucketWidthOverride: bucketWidth,
|
||||
});
|
||||
if (bucketSize <= 0) {
|
||||
bucketSize = range > 0 ? range / bucketCount : 1;
|
||||
}
|
||||
|
||||
const getBucket = (v: number): number =>
|
||||
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
|
||||
|
||||
const frames = buildFrames(result, mergeAllActiveQueries);
|
||||
const histogramsPerSeries: AlignedData[] = frames
|
||||
.filter((frame) => frame.length > 0)
|
||||
.map((frame) => buildHistogramBuckets(frame, getBucket, HIST_SORT));
|
||||
|
||||
if (histogramsPerSeries.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const mergedHistogramData = mergeAlignedDataTables(histogramsPerSeries);
|
||||
replaceUndefinedWithNullInAlignedData(mergedHistogramData);
|
||||
prependNullBinToFirstHistogramSeries(mergedHistogramData, bucketSize);
|
||||
return mergedHistogramData;
|
||||
}
|
||||
|
||||
export function prepareHistogramPanelConfig({
|
||||
widget,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
isDarkMode,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
panelMode: PanelMode;
|
||||
isDarkMode: boolean;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
apiResponse,
|
||||
panelMode,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
});
|
||||
builder.setCursor({
|
||||
drag: {
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: true,
|
||||
},
|
||||
focus: {
|
||||
prox: 1e3,
|
||||
},
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: true,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: true,
|
||||
min: 0,
|
||||
});
|
||||
|
||||
const currentQuery = widget.query;
|
||||
const mergeAllActiveQueries = widget?.mergeAllActiveQueries ?? false;
|
||||
|
||||
// When merged, data has only one y column; add one series to match. Otherwise add one per result.
|
||||
if (mergeAllActiveQueries) {
|
||||
builder.addSeries({
|
||||
label: '',
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
lineColor: '#3f5ecc',
|
||||
fillColor: '#4E74F8',
|
||||
isDarkMode,
|
||||
});
|
||||
} else {
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = currentQuery
|
||||
? getLegend(series, currentQuery, baseLabelName)
|
||||
: baseLabelName;
|
||||
|
||||
builder.addSeries({
|
||||
label: label,
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
barWidthFactor: 1,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
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 { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
@@ -26,7 +26,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
@@ -35,16 +34,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import {
|
||||
MetricRangePayloadProps,
|
||||
MetricRangePayloadV3,
|
||||
} from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { prepareChartData, prepareUPlotConfig } from '../utils';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
getLegend: jest.fn(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getLabelName', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(
|
||||
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
|
||||
),
|
||||
}));
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result,
|
||||
resultType: 'matrix',
|
||||
newResult: (null as unknown) as MetricRangePayloadV3,
|
||||
},
|
||||
});
|
||||
|
||||
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
thresholds: [],
|
||||
customLegendColors: {},
|
||||
...overrides,
|
||||
} as Widgets);
|
||||
|
||||
const defaultTimezone = {
|
||||
name: 'UTC',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
};
|
||||
|
||||
describe('TimeSeriesPanel utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getLabelNameMock.mockReturnValue('baseLabel');
|
||||
getLegendMock.mockImplementation(
|
||||
(_queryData: unknown, _query: unknown, labelName: string) =>
|
||||
`legend-${labelName}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('prepareChartData', () => {
|
||||
it('returns aligned data with timestamps and empty series when result is empty', () => {
|
||||
const apiResponse = createApiResponse([]);
|
||||
|
||||
const data = prepareChartData(apiResponse);
|
||||
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns timestamps and one series of y values for single series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
legend: 'Series A',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const data = prepareChartData(apiResponse);
|
||||
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data[0]).toEqual([1000, 2000]);
|
||||
expect(data[1]).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('merges timestamps and fills missing values with null for multiple series', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[3000, '3'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [
|
||||
[1000, '10'],
|
||||
[2000, '20'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const data = prepareChartData(apiResponse);
|
||||
|
||||
expect(data[0]).toEqual([1000, 2000, 3000]);
|
||||
// First series: 1, null, 3
|
||||
expect(data[1]).toEqual([1, null, 3]);
|
||||
// Second series: 10, 20, null
|
||||
expect(data[2]).toEqual([10, 20, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareUPlotConfig', () => {
|
||||
const baseParams = {
|
||||
widget: createWidget(),
|
||||
isDarkMode: true,
|
||||
currentQuery: {} as Query,
|
||||
onClick: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
apiResponse: createApiResponse(),
|
||||
timezone: defaultTimezone,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
};
|
||||
|
||||
it('adds no series when apiResponse has empty result', () => {
|
||||
const builder = prepareUPlotConfig(baseParams);
|
||||
|
||||
const config = builder.getConfig();
|
||||
// Base series (timestamp) only
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result item with label from getLabelName when no currentQuery', () => {
|
||||
getLegendMock.mockReset();
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: { __name__: 'cpu' },
|
||||
queryName: 'Q1',
|
||||
legend: 'CPU',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[2000, '2'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const builder = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: (null as unknown) as Query,
|
||||
});
|
||||
|
||||
expect(getLabelNameMock).toHaveBeenCalled();
|
||||
expect(getLegendMock).not.toHaveBeenCalled();
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(2);
|
||||
expect(config.series?.[1]).toMatchObject({
|
||||
label: 'baseLabel',
|
||||
scale: 'y',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses getLegend for label when currentQuery is provided', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
legend: 'L1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: {} as Query,
|
||||
});
|
||||
|
||||
expect(getLegendMock).toHaveBeenCalledWith(
|
||||
{
|
||||
legend: 'L1',
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
},
|
||||
{},
|
||||
'baseLabel',
|
||||
);
|
||||
|
||||
const config = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
apiResponse,
|
||||
currentQuery: {} as Query,
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({
|
||||
label: 'legend-baseLabel',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses DrawStyle.Line and VisibilityMode.Never when series has multiple valid points', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[2000, '2'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
const series = config.series?.[1];
|
||||
|
||||
expect(config.series).toHaveLength(2);
|
||||
// Line style and points never for multi-point series (checked via builder API)
|
||||
const legendItems = builder.getLegendItems();
|
||||
expect(Object.keys(legendItems)).toHaveLength(1);
|
||||
// multi-point series → points hidden
|
||||
expect(series).toBeDefined();
|
||||
expect(series!.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('uses DrawStyle.Points and shows points when series has only one valid point', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
values: [
|
||||
[1000, '1'],
|
||||
[2000, 'NaN'],
|
||||
[3000, 'invalid'],
|
||||
],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(2);
|
||||
const seriesConfig = config.series?.[1];
|
||||
expect(seriesConfig).toBeDefined();
|
||||
// Single valid point -> Points draw style (asserted via series config)
|
||||
expect(seriesConfig).toMatchObject({
|
||||
scale: 'y',
|
||||
spanGaps: true,
|
||||
});
|
||||
// single-point series → points shown
|
||||
expect(seriesConfig).toBeDefined();
|
||||
expect(seriesConfig!.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('uses widget customLegendColors to set series stroke color', () => {
|
||||
const widget = createWidget({
|
||||
customLegendColors: { 'legend-baseLabel': '#ff0000' },
|
||||
});
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const builder = prepareUPlotConfig({
|
||||
...baseParams,
|
||||
widget,
|
||||
apiResponse,
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
const seriesConfig = config.series?.[1];
|
||||
expect(seriesConfig).toBeDefined();
|
||||
expect(seriesConfig!.stroke).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('adds multiple series when result has multiple items', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
|
||||
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -14,9 +15,12 @@ import {
|
||||
VisibilityMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import get from 'lodash-es/get';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
@@ -31,6 +35,22 @@ export const prepareChartData = (
|
||||
return [timestampArr, ...yAxisValuesArr];
|
||||
};
|
||||
|
||||
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
|
||||
const rawValues = series.values ?? [];
|
||||
let validPointCount = 0;
|
||||
|
||||
for (const [, rawValue] of rawValues) {
|
||||
if (!isInvalidPlotValue(rawValue)) {
|
||||
validPointCount += 1;
|
||||
if (validPointCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const prepareUPlotConfig = ({
|
||||
widget,
|
||||
isDarkMode,
|
||||
@@ -54,6 +74,13 @@ export const prepareUPlotConfig = ({
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}): UPlotConfigBuilder => {
|
||||
const stepIntervals: ExecStats['stepIntervals'] = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
);
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
@@ -65,9 +92,11 @@ export const prepareUPlotConfig = ({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
apiResponse.data?.result?.forEach((series) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
@@ -80,13 +109,15 @@ export const prepareUPlotConfig = ({
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Line,
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label: label,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: true,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
showPoints: hasSingleValidPoint
|
||||
? VisibilityMode.Always
|
||||
: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
|
||||
@@ -14,11 +14,6 @@ export interface GraphVisibilityState {
|
||||
dataIndex: SeriesVisibilityItem[];
|
||||
}
|
||||
|
||||
export interface SeriesVisibilityState {
|
||||
labels: string[];
|
||||
visibility: boolean[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context in which a panel is rendered. Used to vary behavior (e.g. persistence,
|
||||
* interactions) per context.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { STEP_INTERVAL_MULTIPLIER } from 'lib/uPlotV2/constants';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { buildBaseConfig } from '../baseConfigBuilder';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('lib/uPlotV2/utils', () => ({
|
||||
calculateWidthBasedOnStepInterval: jest.fn(),
|
||||
}));
|
||||
|
||||
const calculateWidthBasedOnStepIntervalMock = jest.requireMock(
|
||||
'lib/uPlotV2/utils',
|
||||
).calculateWidthBasedOnStepInterval as jest.Mock;
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
|
||||
}));
|
||||
|
||||
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
|
||||
({
|
||||
id: 'widget-1',
|
||||
yAxisUnit: 'ms',
|
||||
isLogScale: false,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
thresholds: [],
|
||||
...overrides,
|
||||
} as Widgets);
|
||||
|
||||
const createApiResponse = (
|
||||
overrides: Partial<MetricRangePayloadProps> = {},
|
||||
): MetricRangePayloadProps =>
|
||||
({
|
||||
data: { result: [], resultType: 'matrix', newResult: null },
|
||||
...overrides,
|
||||
} as MetricRangePayloadProps);
|
||||
|
||||
const baseProps = {
|
||||
widget: createWidget(),
|
||||
apiResponse: createApiResponse(),
|
||||
isDarkMode: true,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
};
|
||||
|
||||
describe('buildBaseConfig', () => {
|
||||
it('returns a UPlotConfigBuilder instance', () => {
|
||||
const builder = buildBaseConfig(baseProps);
|
||||
|
||||
expect(builder).toBeDefined();
|
||||
expect(typeof builder.getConfig).toBe('function');
|
||||
expect(typeof builder.getLegendItems).toBe('function');
|
||||
});
|
||||
|
||||
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
panelMode: PanelMode.DASHBOARD_VIEW,
|
||||
widget: createWidget({ id: 'my-widget' }),
|
||||
});
|
||||
|
||||
expect(builder.getWidgetId()).toBe('my-widget');
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('configures builder with IN_MEMORY selection when panelMode is DASHBOARD_EDIT', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
panelMode: PanelMode.DASHBOARD_EDIT,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(false);
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toBeDefined();
|
||||
});
|
||||
|
||||
it('passes stepInterval to builder and cursor prox uses width * multiplier', () => {
|
||||
const stepInterval = 60;
|
||||
const mockWidth = 100;
|
||||
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
stepInterval,
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
const prox = config.cursor?.hover?.prox;
|
||||
expect(typeof prox).toBe('function');
|
||||
|
||||
const uPlotInstance = {} as uPlot;
|
||||
const proxResult = (prox as (u: uPlot) => number)(uPlotInstance);
|
||||
|
||||
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
|
||||
uPlotInstance,
|
||||
stepInterval,
|
||||
});
|
||||
expect(proxResult).toBe(mockWidth * STEP_INTERVAL_MULTIPLIER);
|
||||
});
|
||||
|
||||
it('adds x scale with time config and min/max when provided', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
minTimeScale: 1000,
|
||||
maxTimeScale: 2000,
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.scales?.x).toBeDefined();
|
||||
expect(config.scales?.x?.time).toBe(true);
|
||||
const range = config.scales?.x?.range;
|
||||
expect(Array.isArray(range)).toBe(true);
|
||||
expect((range as [number, number])[0]).toBe(1000);
|
||||
});
|
||||
|
||||
it('configures log scale on y axis when widget.isLogScale is true', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
widget: createWidget({ isLogScale: true }),
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.scales?.y).toBeDefined();
|
||||
expect(config.scales?.y?.log).toBe(10);
|
||||
});
|
||||
|
||||
it('adds onClick plugin when onClick is a function', () => {
|
||||
const onClickPlugin = jest.requireMock('lib/uPlotLib/plugins/onClickPlugin')
|
||||
.default;
|
||||
const onClick = jest.fn();
|
||||
|
||||
buildBaseConfig({
|
||||
...baseProps,
|
||||
onClick,
|
||||
apiResponse: createApiResponse(),
|
||||
});
|
||||
|
||||
expect(onClickPlugin).toHaveBeenCalledWith({
|
||||
onClick,
|
||||
apiResponse: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add onClick plugin when onClick is not a function', () => {
|
||||
const onClickPlugin = jest.requireMock('lib/uPlotLib/plugins/onClickPlugin')
|
||||
.default;
|
||||
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
const plugins = config.plugins ?? [];
|
||||
expect(
|
||||
plugins.some((p) => (p as { name?: string }).name === 'onClickPlugin'),
|
||||
).toBe(false);
|
||||
expect(onClickPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds thresholds from widget', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
widget: createWidget({
|
||||
thresholds: [
|
||||
{
|
||||
thresholdValue: 80,
|
||||
thresholdColor: '#ff0000',
|
||||
thresholdUnit: 'ms',
|
||||
thresholdLabel: 'High',
|
||||
},
|
||||
] as Widgets['thresholds'],
|
||||
}),
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
expect(drawHooks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('adds x and y axes with correct scaleKeys and panelType', () => {
|
||||
const builder = buildBaseConfig(baseProps);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.axes).toHaveLength(2);
|
||||
expect(config.axes?.[0].scale).toBe('x');
|
||||
expect(config.axes?.[1].scale).toBe('y');
|
||||
});
|
||||
|
||||
it('sets tzDate when timezone is provided', () => {
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
timezone: {
|
||||
name: 'America/New_York',
|
||||
value: 'America/New_York',
|
||||
offset: 'UTC-5',
|
||||
searchIndex: 'America/New_York',
|
||||
},
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.tzDate).toBeDefined();
|
||||
expect(typeof config.tzDate).toBe('function');
|
||||
});
|
||||
|
||||
it('leaves tzDate undefined when timezone is not provided', () => {
|
||||
const builder = buildBaseConfig(baseProps);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.tzDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('register setSelect hook when onDragSelect is provided', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = buildBaseConfig({
|
||||
...baseProps,
|
||||
onDragSelect,
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.hooks?.setSelect).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -62,10 +62,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns visibility by index including duplicate labels', () => {
|
||||
@@ -85,10 +85,11 @@ describe('legendVisibilityUtils', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'CPU', 'Memory'],
|
||||
visibility: [true, false, false],
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON in localStorage', () => {
|
||||
@@ -127,10 +128,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a new widget entry when other widgets already exist', () => {
|
||||
@@ -149,7 +150,7 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-new');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({ labels: ['CPU'], visibility: [false] });
|
||||
expect(stored).toEqual([{ label: 'CPU', show: false }]);
|
||||
});
|
||||
|
||||
it('updates existing widget visibility when entry already exists', () => {
|
||||
@@ -175,10 +176,10 @@ describe('legendVisibilityUtils', () => {
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [false, true],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('silently handles malformed existing JSON without throwing', () => {
|
||||
@@ -201,10 +202,10 @@ describe('legendVisibilityUtils', () => {
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['x-axis', 'CPU'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
expect(stored).toEqual([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
const expected = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
@@ -231,14 +232,12 @@ describe('legendVisibilityUtils', () => {
|
||||
{ label: 'B', show: true },
|
||||
]);
|
||||
|
||||
expect(getStoredSeriesVisibility('widget-a')).toEqual({
|
||||
labels: ['A'],
|
||||
visibility: [true],
|
||||
});
|
||||
expect(getStoredSeriesVisibility('widget-b')).toEqual({
|
||||
labels: ['B'],
|
||||
visibility: [true],
|
||||
});
|
||||
expect(getStoredSeriesVisibility('widget-a')).toEqual([
|
||||
{ label: 'A', show: true },
|
||||
]);
|
||||
expect(getStoredSeriesVisibility('widget-b')).toEqual([
|
||||
{ label: 'B', show: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls setItem with storage key and stringified visibility states', () => {
|
||||
|
||||
@@ -19,13 +19,14 @@ export interface BaseConfigBuilderProps {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
isDarkMode: boolean;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
timezone: Timezone;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
timezone?: Timezone;
|
||||
panelMode: PanelMode;
|
||||
panelType: PANEL_TYPES;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
stepInterval?: number;
|
||||
}
|
||||
|
||||
export function buildBaseConfig({
|
||||
@@ -39,9 +40,12 @@ export function buildBaseConfig({
|
||||
panelType,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
onDragSelect,
|
||||
@@ -54,6 +58,7 @@ export function buildBaseConfig({
|
||||
].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
NULL_EXPAND,
|
||||
NULL_REMOVE,
|
||||
NULL_RETAIN,
|
||||
} from 'container/PanelWrapper/constants';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Expands contiguous runs of `null` values to the left and right of their
|
||||
* original positions so that visual gaps in the series are continuous.
|
||||
*
|
||||
* This is used when `NULL_EXPAND` mode is selected while joining series.
|
||||
*/
|
||||
function propagateNullsAcrossNeighbors(
|
||||
seriesValues: Array<number | null>,
|
||||
nullIndices: number[],
|
||||
alignedLength: number,
|
||||
): void {
|
||||
for (
|
||||
let i = 0, currentIndex, lastExpandedNullIndex = -1;
|
||||
i < nullIndices.length;
|
||||
i++
|
||||
) {
|
||||
const nullIndex = nullIndices[i];
|
||||
|
||||
if (nullIndex > lastExpandedNullIndex) {
|
||||
// expand left until we hit a non-null value
|
||||
currentIndex = nullIndex - 1;
|
||||
while (currentIndex >= 0 && seriesValues[currentIndex] == null) {
|
||||
seriesValues[currentIndex--] = null;
|
||||
}
|
||||
|
||||
// expand right until we hit a non-null value
|
||||
currentIndex = nullIndex + 1;
|
||||
while (currentIndex < alignedLength && seriesValues[currentIndex] == null) {
|
||||
seriesValues[(lastExpandedNullIndex = currentIndex++)] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple uPlot `AlignedData` tables into a single aligned table.
|
||||
*
|
||||
* - Merges and sorts all distinct x-values from each table.
|
||||
* - Re-aligns every series onto the merged x-axis.
|
||||
* - Applies per-series null handling (`NULL_REMOVE`, `NULL_RETAIN`, `NULL_EXPAND`).
|
||||
*/
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
export function mergeAlignedDataTables(
|
||||
alignedTables: AlignedData[],
|
||||
nullModes?: number[][],
|
||||
): AlignedData {
|
||||
let mergedXValues: Set<number>;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
mergedXValues = new Set();
|
||||
|
||||
// Collect all unique x-values from every table.
|
||||
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
|
||||
const table = alignedTables[tableIndex];
|
||||
const xValues = table[0];
|
||||
const xLength = xValues.length;
|
||||
|
||||
for (let i = 0; i < xLength; i++) {
|
||||
mergedXValues.add(xValues[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted, merged x-axis used by the final result.
|
||||
const alignedData: (number | null | undefined)[][] = [
|
||||
Array.from(mergedXValues).sort((a, b) => a - b),
|
||||
];
|
||||
|
||||
const alignedLength = alignedData[0].length;
|
||||
|
||||
// Map from x-value to its index in the merged x-axis.
|
||||
const xValueToIndexMap = new Map<number, number>();
|
||||
|
||||
for (let i = 0; i < alignedLength; i++) {
|
||||
xValueToIndexMap.set(alignedData[0][i] as number, i);
|
||||
}
|
||||
|
||||
// Re-align all series from all tables onto the merged x-axis.
|
||||
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
|
||||
const table = alignedTables[tableIndex];
|
||||
const xValues = table[0];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < table.length; seriesIndex++) {
|
||||
const seriesValues = table[seriesIndex];
|
||||
|
||||
const alignedSeriesValues = Array(alignedLength).fill(undefined);
|
||||
|
||||
const nullHandlingMode = nullModes
|
||||
? nullModes[tableIndex][seriesIndex]
|
||||
: NULL_RETAIN;
|
||||
|
||||
const nullIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < seriesValues.length; i++) {
|
||||
const valueAtPoint = seriesValues[i];
|
||||
const alignedIndex = xValueToIndexMap.get(xValues[i]);
|
||||
|
||||
if (alignedIndex == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueAtPoint === null) {
|
||||
if (nullHandlingMode !== NULL_REMOVE) {
|
||||
alignedSeriesValues[alignedIndex] = valueAtPoint;
|
||||
|
||||
if (nullHandlingMode === NULL_EXPAND) {
|
||||
nullIndices.push(alignedIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alignedSeriesValues[alignedIndex] = valueAtPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally expand nulls to visually preserve gaps.
|
||||
propagateNullsAcrossNeighbors(
|
||||
alignedSeriesValues,
|
||||
nullIndices,
|
||||
alignedLength,
|
||||
);
|
||||
|
||||
alignedData.push(alignedSeriesValues);
|
||||
}
|
||||
}
|
||||
|
||||
return alignedData as AlignedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds histogram buckets from raw values.
|
||||
*
|
||||
* - Each value is mapped into a bucket via `getBucketForValue`.
|
||||
* - Counts how many values fall into each bucket.
|
||||
* - Optionally sorts buckets using the provided comparator.
|
||||
*/
|
||||
export function buildHistogramBuckets(
|
||||
values: number[],
|
||||
getBucketForValue: (value: number) => number,
|
||||
sortBuckets?: ((a: number, b: number) => number) | null,
|
||||
): AlignedData {
|
||||
const bucketMap = new Map<number, { value: number; count: number }>();
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
let value = values[i];
|
||||
|
||||
if (value != null) {
|
||||
value = getBucketForValue(value);
|
||||
}
|
||||
|
||||
const bucket = bucketMap.get(value);
|
||||
|
||||
if (bucket) {
|
||||
bucket.count++;
|
||||
} else {
|
||||
bucketMap.set(value, { value, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = [...bucketMap.values()];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
sortBuckets && buckets.sort((a, b) => sortBuckets(a.value, b.value));
|
||||
|
||||
const bucketValues = Array(buckets.length);
|
||||
const bucketCounts = Array(buckets.length);
|
||||
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
bucketValues[i] = buckets[i].value;
|
||||
bucketCounts[i] = buckets[i].count;
|
||||
}
|
||||
|
||||
return [bucketValues, bucketCounts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates an `AlignedData` instance, replacing all `undefined` entries
|
||||
* with explicit `null` values so uPlot treats them as gaps.
|
||||
*/
|
||||
export function replaceUndefinedWithNullInAlignedData(
|
||||
data: AlignedData,
|
||||
): AlignedData {
|
||||
const seriesList = data as (number | null | undefined)[][];
|
||||
for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex++) {
|
||||
for (
|
||||
let pointIndex = 0;
|
||||
pointIndex < seriesList[seriesIndex].length;
|
||||
pointIndex++
|
||||
) {
|
||||
if (seriesList[seriesIndex][pointIndex] === undefined) {
|
||||
seriesList[seriesIndex][pointIndex] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the first histogram series has a leading "empty" bin so that
|
||||
* all series line up visually when rendered as bars.
|
||||
*
|
||||
* - Prepends a new x-value (first x - `bucketSize`) to the first series.
|
||||
* - Prepends `null` to all subsequent series at the same index.
|
||||
*/
|
||||
export function prependNullBinToFirstHistogramSeries(
|
||||
alignedData: AlignedData,
|
||||
bucketSize: number,
|
||||
): void {
|
||||
const seriesList = alignedData as (number | null)[][];
|
||||
if (
|
||||
seriesList.length > 0 &&
|
||||
seriesList[0].length > 0 &&
|
||||
seriesList[0][0] !== null
|
||||
) {
|
||||
seriesList[0].unshift(seriesList[0][0] - bucketSize);
|
||||
for (let seriesIndex = 1; seriesIndex < seriesList.length; seriesIndex++) {
|
||||
seriesList[seriesIndex].unshift(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import {
|
||||
GraphVisibilityState,
|
||||
SeriesVisibilityItem,
|
||||
SeriesVisibilityState,
|
||||
} from '../types';
|
||||
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
|
||||
|
||||
/**
|
||||
* Retrieves the stored series visibility for a specific widget from localStorage by index.
|
||||
@@ -14,7 +10,7 @@ import {
|
||||
*/
|
||||
export function getStoredSeriesVisibility(
|
||||
widgetId: string,
|
||||
): SeriesVisibilityState | null {
|
||||
): SeriesVisibilityItem[] | null {
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
|
||||
@@ -29,10 +25,7 @@ export function getStoredSeriesVisibility(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
labels: widgetState.dataIndex.map((item) => item.label),
|
||||
visibility: widgetState.dataIndex.map((item) => item.show),
|
||||
};
|
||||
return widgetState.dataIndex;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
// If the stored data is malformed, remove it
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Virtuoso, VirtuosoHandle } 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,6 +12,8 @@ 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';
|
||||
@@ -40,6 +43,15 @@ function EntityLogs({
|
||||
category,
|
||||
queryKeyFilters,
|
||||
}: Props): JSX.Element {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const basePayload = getEntityEventsOrLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
@@ -62,29 +74,40 @@ function EntityLogs({
|
||||
basePayload,
|
||||
});
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: 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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
(_: 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],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
@@ -131,6 +154,7 @@ function EntityLogs({
|
||||
<Virtuoso
|
||||
className="entity-logs-virtuoso"
|
||||
key="entity-logs-virtuoso"
|
||||
ref={virtuosoRef}
|
||||
data={logs}
|
||||
endReached={loadMoreLogs}
|
||||
totalCount={logs.length}
|
||||
@@ -154,7 +178,21 @@ function EntityLogs({
|
||||
)}
|
||||
{isError && !isLoading && <LogsError />}
|
||||
{!isLoading && !isError && logs.length > 0 && (
|
||||
<div className="entity-logs-list-container">{renderContent}</div>
|
||||
<div className="entity-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>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Card, Typography } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import ListLogView from 'components/Logs/ListLogView';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
@@ -14,8 +13,9 @@ import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -38,10 +38,11 @@ function LiveLogsList({
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery,
|
||||
onSetActiveLog,
|
||||
} = useActiveLog();
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
// get only data from the logs object
|
||||
const formattedLogs: ILog[] = useMemo(
|
||||
@@ -65,42 +66,56 @@ function LiveLogsList({
|
||||
...options.selectColumns,
|
||||
]);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs: formattedLogs,
|
||||
virtuosoRef: ref,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, log: ILog): JSX.Element => {
|
||||
if (options.format === 'raw') {
|
||||
return (
|
||||
<RawLogView
|
||||
key={log.id}
|
||||
data={log}
|
||||
linesPerRow={options.maxLines}
|
||||
selectedFields={selectedFields}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
<div key={log.id}>
|
||||
<RawLogView
|
||||
data={log}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
linesPerRow={options.maxLines}
|
||||
selectedFields={selectedFields}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListLogView
|
||||
key={log.id}
|
||||
logData={log}
|
||||
selectedFields={selectedFields}
|
||||
linesPerRow={options.maxLines}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onSetActiveLog={onSetActiveLog}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
<div key={log.id}>
|
||||
<ListLogView
|
||||
logData={log}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
selectedFields={selectedFields}
|
||||
linesPerRow={options.maxLines}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
handleChangeSelectedView,
|
||||
onAddToQuery,
|
||||
onSetActiveLog,
|
||||
options.fontSize,
|
||||
options.format,
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLog?.id,
|
||||
selectedFields,
|
||||
onAddToQuery,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -156,6 +171,10 @@ function LiveLogsList({
|
||||
activeLogIndex,
|
||||
}}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={formattedLogs}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
activeLog={activeLog}
|
||||
/>
|
||||
) : (
|
||||
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
|
||||
@@ -173,14 +192,17 @@ function LiveLogsList({
|
||||
</InfinityWrapperStyled>
|
||||
)}
|
||||
|
||||
{activeLog && (
|
||||
{activeLog && selectedTab && (
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={formattedLogs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -395,7 +395,7 @@ export default function TableViewActions(
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div>
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
@@ -481,7 +481,7 @@ export default function TableViewActions(
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={
|
||||
<div>
|
||||
<div data-log-detail-ignore="true">
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
|
||||
import { ColumnTypeRender } from 'components/Logs/TableView/types';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
@@ -22,22 +23,27 @@ interface TableRowProps {
|
||||
tableColumns: ColumnsType<Record<string, unknown>>;
|
||||
index: number;
|
||||
log: Record<string, unknown>;
|
||||
handleSetActiveContextLog: (log: ILog) => void;
|
||||
onShowLogDetails: (log: ILog) => void;
|
||||
onShowLogDetails?: (
|
||||
log: ILog,
|
||||
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
|
||||
) => void;
|
||||
logs: ILog[];
|
||||
hasActions: boolean;
|
||||
fontSize: FontSize;
|
||||
isActiveLog?: boolean;
|
||||
onClearActiveLog?: () => void;
|
||||
}
|
||||
|
||||
export default function TableRow({
|
||||
tableColumns,
|
||||
index,
|
||||
log,
|
||||
handleSetActiveContextLog,
|
||||
onShowLogDetails,
|
||||
logs,
|
||||
hasActions,
|
||||
fontSize,
|
||||
isActiveLog,
|
||||
onClearActiveLog,
|
||||
}: TableRowProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -52,21 +58,31 @@ export default function TableRow({
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!handleSetActiveContextLog || !currentLog) {
|
||||
if (!currentLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSetActiveContextLog(currentLog);
|
||||
onShowLogDetails?.(currentLog, VIEW_TYPES.CONTEXT);
|
||||
},
|
||||
[currentLog, handleSetActiveContextLog],
|
||||
[currentLog, onShowLogDetails],
|
||||
);
|
||||
|
||||
const handleShowLogDetails = useCallback(() => {
|
||||
if (!onShowLogDetails || !currentLog) {
|
||||
if (!currentLog) {
|
||||
return;
|
||||
}
|
||||
onShowLogDetails(currentLog);
|
||||
}, [currentLog, onShowLogDetails]);
|
||||
|
||||
// If this log is already active, close the detail drawer
|
||||
if (isActiveLog && onClearActiveLog) {
|
||||
onClearActiveLog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, open the detail drawer for this log
|
||||
if (onShowLogDetails) {
|
||||
onShowLogDetails(currentLog);
|
||||
}
|
||||
}, [currentLog, onShowLogDetails, isActiveLog, onClearActiveLog]);
|
||||
|
||||
const hasSingleColumn =
|
||||
tableColumns.filter((column) => column.key !== 'state-indicator').length ===
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TableVirtuoso,
|
||||
TableVirtuosoHandle,
|
||||
} from 'react-virtuoso';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||
import { useTableView } from 'components/Logs/TableView/useTableView';
|
||||
@@ -58,26 +57,40 @@ const CustomTableRow: TableComponents<ILog>['TableRow'] = ({
|
||||
|
||||
const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
function InfinityTableView(
|
||||
{ isLoading, tableViewProps, infitiyTableProps, handleChangeSelectedView },
|
||||
ref,
|
||||
): JSX.Element | null {
|
||||
const {
|
||||
activeLog: activeContextLog,
|
||||
onSetActiveLog: handleSetActiveContextLog,
|
||||
onClearActiveLog: handleClearActiveContextLog,
|
||||
onAddToQuery: handleAddToQuery,
|
||||
} = useActiveLog();
|
||||
const {
|
||||
activeLog,
|
||||
{
|
||||
isLoading,
|
||||
tableViewProps,
|
||||
infitiyTableProps,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery,
|
||||
} = useActiveLog();
|
||||
activeLog,
|
||||
},
|
||||
ref,
|
||||
): JSX.Element | null {
|
||||
const { activeLog: activeContextLog } = useActiveLog();
|
||||
|
||||
const onSetActiveLogExpand = useCallback(
|
||||
(log: ILog) => {
|
||||
onSetActiveLog?.(log);
|
||||
},
|
||||
[onSetActiveLog],
|
||||
);
|
||||
|
||||
const onSetActiveLogContext = useCallback(
|
||||
(log: ILog) => {
|
||||
onSetActiveLog?.(log, VIEW_TYPES.CONTEXT);
|
||||
},
|
||||
[onSetActiveLog],
|
||||
);
|
||||
|
||||
const onCloseActiveLog = useCallback(() => {
|
||||
onClearActiveLog?.();
|
||||
}, [onClearActiveLog]);
|
||||
|
||||
const { dataSource, columns } = useTableView({
|
||||
...tableViewProps,
|
||||
onClickExpand: onSetActiveLog,
|
||||
onOpenLogsContext: handleSetActiveContextLog,
|
||||
onClickExpand: onSetActiveLogExpand,
|
||||
onOpenLogsContext: onSetActiveLogContext,
|
||||
});
|
||||
|
||||
const { draggedColumns, onDragColumns } = useDragColumns<
|
||||
@@ -98,27 +111,32 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => (
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
handleSetActiveContextLog={handleSetActiveContextLog}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
/>
|
||||
),
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => {
|
||||
return (
|
||||
<div key={log.id as string}>
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
handleSetActiveContextLog,
|
||||
tableColumns,
|
||||
tableViewProps.fontSize,
|
||||
tableViewProps.logs,
|
||||
onSetActiveLog,
|
||||
tableViewProps.logs,
|
||||
tableViewProps.fontSize,
|
||||
activeLog?.id,
|
||||
onCloseActiveLog,
|
||||
],
|
||||
);
|
||||
|
||||
const tableHeader = useCallback(
|
||||
() => (
|
||||
<tr>
|
||||
@@ -179,24 +197,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
? { endReached: infitiyTableProps.onEndReached }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{activeContextLog && (
|
||||
<LogDetail
|
||||
log={activeContextLog}
|
||||
onClose={handleClearActiveContextLog}
|
||||
onAddToQuery={handleAddToQuery}
|
||||
selectedTab={VIEW_TYPES.CONTEXT}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { UseTableViewProps } from 'components/Logs/TableView/types';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
export type InfinityTableProps = {
|
||||
isLoading?: boolean;
|
||||
@@ -8,4 +10,11 @@ export type InfinityTableProps = {
|
||||
onEndReached: (index: number) => void;
|
||||
};
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
logs?: ILog[];
|
||||
onSetActiveLog?: (
|
||||
log: ILog,
|
||||
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
|
||||
) => void;
|
||||
onClearActiveLog?: () => void;
|
||||
activeLog?: ILog | null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
// components
|
||||
import ListLogView from 'components/Logs/ListLogView';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
@@ -16,8 +15,9 @@ import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import APIError from 'types/api/error';
|
||||
// interfaces
|
||||
@@ -55,10 +55,11 @@ function LogsExplorerList({
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery,
|
||||
onSetActiveLog,
|
||||
} = useActiveLog();
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -82,6 +83,12 @@ function LogsExplorerList({
|
||||
() => convertKeysToColumnFields(options.selectColumns),
|
||||
[options],
|
||||
);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef: ref,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && logs.length !== 0) {
|
||||
logEvent('Logs Explorer: Data present', {
|
||||
@@ -94,40 +101,48 @@ function LogsExplorerList({
|
||||
(_: number, log: ILog): JSX.Element => {
|
||||
if (options.format === 'raw') {
|
||||
return (
|
||||
<RawLogView
|
||||
key={log.id}
|
||||
data={log}
|
||||
linesPerRow={options.maxLines}
|
||||
selectedFields={selectedFields}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
<div key={log.id}>
|
||||
<RawLogView
|
||||
data={log}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
linesPerRow={options.maxLines}
|
||||
selectedFields={selectedFields}
|
||||
fontSize={options.fontSize}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListLogView
|
||||
key={log.id}
|
||||
logData={log}
|
||||
selectedFields={selectedFields}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onSetActiveLog={onSetActiveLog}
|
||||
activeLog={activeLog}
|
||||
fontSize={options.fontSize}
|
||||
linesPerRow={options.maxLines}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
<div key={log.id}>
|
||||
<ListLogView
|
||||
logData={log}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
selectedFields={selectedFields}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
activeLog={activeLog}
|
||||
fontSize={options.fontSize}
|
||||
linesPerRow={options.maxLines}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeLog,
|
||||
handleChangeSelectedView,
|
||||
onAddToQuery,
|
||||
onSetActiveLog,
|
||||
options.fontSize,
|
||||
options.format,
|
||||
options.fontSize,
|
||||
options.maxLines,
|
||||
activeLog,
|
||||
selectedFields,
|
||||
onAddToQuery,
|
||||
handleSetActiveLog,
|
||||
handleChangeSelectedView,
|
||||
handleCloseLogDetail,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -153,6 +168,10 @@ function LogsExplorerList({
|
||||
}}
|
||||
infitiyTableProps={{ onEndReached }}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={logs}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
activeLog={activeLog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -199,6 +218,9 @@ function LogsExplorerList({
|
||||
getItemContent,
|
||||
selectedFields,
|
||||
handleChangeSelectedView,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
activeLog,
|
||||
]);
|
||||
|
||||
const isTraceToLogsNavigation = useMemo(() => {
|
||||
@@ -278,14 +300,19 @@ function LogsExplorerList({
|
||||
{renderContent}
|
||||
</InfinityWrapperStyled>
|
||||
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -466,7 +466,10 @@ function LogsExplorerViewsContainer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="logs-explorer-views-type-content">
|
||||
<div
|
||||
className="logs-explorer-views-type-content"
|
||||
data-log-detail-ignore="true"
|
||||
>
|
||||
{showLiveLogs && (
|
||||
<LiveLogs handleChangeSelectedView={handleChangeSelectedView} />
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -16,7 +15,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Controls from 'container/Controls';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import { useLogsData } from 'hooks/useLogsData';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
@@ -83,24 +82,24 @@ function LogsPanelComponent({
|
||||
() => logs.map((log) => FlatLogData(log) as RowData),
|
||||
[logs],
|
||||
);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery,
|
||||
} = useActiveLog();
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const handleRow = useCallback(
|
||||
(record: RowData): HTMLAttributes<RowData> => ({
|
||||
onClick: (): void => {
|
||||
const log = logs.find((item) => item.id === record.id);
|
||||
if (log) {
|
||||
onSetActiveLog(log);
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[logs, onSetActiveLog],
|
||||
[handleSetActiveLog, logs],
|
||||
);
|
||||
|
||||
const handleRequestData = (newOffset: number): void => {
|
||||
@@ -132,7 +131,7 @@ function LogsPanelComponent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="logs-table">
|
||||
<div className="logs-table" data-log-detail-ignore="true">
|
||||
<div className="resize-table">
|
||||
<OverlayScrollbar>
|
||||
<ResizeTable
|
||||
@@ -166,15 +165,19 @@ function LogsPanelComponent({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
/>
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,28 +51,6 @@ function WidgetGraphContainer({
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedGraph !== PANEL_TYPES.LIST &&
|
||||
selectedGraph !== PANEL_TYPES.VALUE &&
|
||||
queryResponse.data?.payload.data?.result?.length === 0
|
||||
) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<Typography>No Data</Typography>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(selectedGraph === PANEL_TYPES.LIST || selectedGraph === PANEL_TYPES.VALUE) &&
|
||||
queryResponse.data?.payload?.data?.newResult?.data?.result?.length === 0
|
||||
) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<Typography>No Data</Typography>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (queryResponse.isIdle) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
|
||||
@@ -477,47 +477,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
.opentelemetry-radio-container {
|
||||
.observability-tools-radio-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
width: 528px;
|
||||
|
||||
.opentelemetry-radio-group {
|
||||
width: 100%;
|
||||
.observability-tool-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
|
||||
.opentelemetry-radio-items-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
label {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opentelemetry-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
min-width: 258px;
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
button[role='radio'] {
|
||||
&[data-state='unchecked'] {
|
||||
border-color: var(--l3-border) !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.observability-tool-others-item {
|
||||
.onboarding-questionaire-other-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.migration-timeline-radio-container,
|
||||
.opentelemetry-radio-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
width: 528px;
|
||||
|
||||
.migration-timeline-radio-item,
|
||||
.opentelemetry-radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
width: calc((528px - 12px) / 2);
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
|
||||
label {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ant-radio {
|
||||
.ant-radio-inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
button[role='radio'] {
|
||||
&[data-state='unchecked'] {
|
||||
border-color: var(--l3-border) !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,27 +997,6 @@
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
.opentelemetry-radio-container {
|
||||
.opentelemetry-radio-group {
|
||||
.opentelemetry-radio-items-wrapper {
|
||||
.opentelemetry-radio-item {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-radio {
|
||||
.ant-radio-inner {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-back-button {
|
||||
border-color: var(--text-vanilla-300);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Radio, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/es/radio';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupLabel,
|
||||
} from '@signozhq/radio-group';
|
||||
import { Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import editOrg from 'api/organization/editOrg';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import '../OnboardingQuestionaire.styles.scss';
|
||||
|
||||
export interface OrgData {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface OrgDetails {
|
||||
organisationName: string;
|
||||
usesObservability: boolean | null;
|
||||
observabilityTool: string | null;
|
||||
otherTool: string | null;
|
||||
usesOtel: boolean | null;
|
||||
migrationTimeline: string | null;
|
||||
}
|
||||
|
||||
interface OrgQuestionsProps {
|
||||
currentOrgData: OrgData | null;
|
||||
orgDetails: OrgDetails;
|
||||
onNext: (details: OrgDetails) => void;
|
||||
}
|
||||
@@ -45,19 +38,14 @@ const observabilityTools = {
|
||||
Others: 'Others',
|
||||
};
|
||||
|
||||
function OrgQuestions({
|
||||
currentOrgData,
|
||||
orgDetails,
|
||||
onNext,
|
||||
}: OrgQuestionsProps): JSX.Element {
|
||||
const { updateOrg } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const migrationTimelineOptions = {
|
||||
lessThanMonth: 'Less than a month',
|
||||
oneToThreeMonths: '1-3 months',
|
||||
greaterThanThreeMonths: 'Greater than 3 months',
|
||||
justExploring: 'Just exploring',
|
||||
};
|
||||
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
|
||||
const [organisationName, setOrganisationName] = useState<string>(
|
||||
orgDetails?.organisationName || '',
|
||||
);
|
||||
function OrgQuestions({ orgDetails, onNext }: OrgQuestionsProps): JSX.Element {
|
||||
const [observabilityTool, setObservabilityTool] = useState<string | null>(
|
||||
orgDetails?.observabilityTool || null,
|
||||
);
|
||||
@@ -66,92 +54,33 @@ function OrgQuestions({
|
||||
);
|
||||
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setOrganisationName(orgDetails.organisationName);
|
||||
}, [orgDetails.organisationName]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const [usesOtel, setUsesOtel] = useState<boolean | null>(orgDetails.usesOtel);
|
||||
const [migrationTimeline, setMigrationTimeline] = useState<string | null>(
|
||||
orgDetails?.migrationTimeline || null,
|
||||
);
|
||||
|
||||
const handleOrgNameUpdate = async (): Promise<void> => {
|
||||
const showMigrationQuestion =
|
||||
observabilityTool !== null && observabilityTool !== 'None';
|
||||
|
||||
const handleNext = (): void => {
|
||||
const usesObservability =
|
||||
!observabilityTool?.includes('None') && observabilityTool !== null;
|
||||
|
||||
/* Early bailout if orgData is not set or if the organisation name is not set or if the organisation name is empty or if the organisation name is the same as the one in the orgData */
|
||||
if (
|
||||
!currentOrgData ||
|
||||
!organisationName ||
|
||||
organisationName === '' ||
|
||||
orgDetails.organisationName === organisationName
|
||||
) {
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
migrationTimeline,
|
||||
});
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { statusCode, error } = await editOrg({
|
||||
displayName: organisationName,
|
||||
orgId: currentOrgData.id,
|
||||
});
|
||||
if (statusCode === 204) {
|
||||
updateOrg(currentOrgData?.id, organisationName);
|
||||
|
||||
logEvent('Org Onboarding: Org Name Updated', {
|
||||
organisationName,
|
||||
});
|
||||
|
||||
logEvent('Org Onboarding: Answered', {
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
|
||||
onNext({
|
||||
organisationName,
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
});
|
||||
} else {
|
||||
logEvent('Org Onboarding: Org Name Update Failed', {
|
||||
organisationName: orgDetails.organisationName,
|
||||
});
|
||||
|
||||
notifications.error({
|
||||
message:
|
||||
error ||
|
||||
t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
onNext({
|
||||
usesObservability,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
usesOtel,
|
||||
migrationTimeline,
|
||||
});
|
||||
};
|
||||
|
||||
const isValidUsesObservability = (): boolean => {
|
||||
@@ -173,22 +102,29 @@ function OrgQuestions({
|
||||
|
||||
useEffect(() => {
|
||||
const isValidObservability = isValidUsesObservability();
|
||||
const isMigrationValid = !showMigrationQuestion || migrationTimeline !== null;
|
||||
|
||||
if (organisationName !== '' && usesOtel !== null && isValidObservability) {
|
||||
if (usesOtel !== null && isValidObservability && isMigrationValid) {
|
||||
setIsNextDisabled(false);
|
||||
} else {
|
||||
setIsNextDisabled(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organisationName, usesOtel, observabilityTool, otherTool]);
|
||||
}, [
|
||||
usesOtel,
|
||||
observabilityTool,
|
||||
otherTool,
|
||||
migrationTimeline,
|
||||
showMigrationQuestion,
|
||||
]);
|
||||
|
||||
const createObservabilityToolHandler = (tool: string) => (
|
||||
checked: boolean,
|
||||
): void => {
|
||||
if (checked) {
|
||||
setObservabilityTool(tool);
|
||||
} else if (observabilityTool === tool) {
|
||||
setObservabilityTool(null);
|
||||
const handleObservabilityToolChange = (value: string): void => {
|
||||
setObservabilityTool(value);
|
||||
if (value !== 'Others') {
|
||||
setOtherTool('');
|
||||
}
|
||||
if (value === 'None') {
|
||||
setMigrationTimeline(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,10 +132,6 @@ function OrgQuestions({
|
||||
setUsesOtel(value === 'yes');
|
||||
};
|
||||
|
||||
const handleOnNext = (): void => {
|
||||
handleOrgNameUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
<div className="onboarding-header-section">
|
||||
@@ -214,40 +146,24 @@ function OrgQuestions({
|
||||
|
||||
<div className="questions-form-container">
|
||||
<div className="questions-form">
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="organisationName">
|
||||
Name of your company
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="organisationName"
|
||||
id="organisationName"
|
||||
placeholder="e.g. Simpsonville"
|
||||
autoComplete="off"
|
||||
value={organisationName}
|
||||
onChange={(e): void => setOrganisationName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="question" htmlFor="observabilityTool">
|
||||
Which observability tool do you currently use?
|
||||
</label>
|
||||
<div className="observability-tools-checkbox-container">
|
||||
<RadioGroup
|
||||
value={observabilityTool || ''}
|
||||
onValueChange={handleObservabilityToolChange}
|
||||
className="observability-tools-radio-container"
|
||||
>
|
||||
{Object.entries(observabilityTools).map(([tool, label]) => {
|
||||
if (tool === 'Others') {
|
||||
return (
|
||||
<div
|
||||
key={tool}
|
||||
className="checkbox-item observability-tool-checkbox-item observability-tool-others-item"
|
||||
className="radio-item observability-tool-radio-item observability-tool-others-item"
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${tool}`}
|
||||
checked={observabilityTool === tool}
|
||||
onCheckedChange={createObservabilityToolHandler(tool)}
|
||||
labelName={observabilityTool === 'Others' ? '' : label}
|
||||
/>
|
||||
{observabilityTool === 'Others' && (
|
||||
<RadioGroupItem value={tool} id={`radio-${tool}`} />
|
||||
{observabilityTool === 'Others' ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="onboarding-questionaire-other-input"
|
||||
@@ -256,55 +172,60 @@ function OrgQuestions({
|
||||
autoFocus
|
||||
onChange={(e): void => setOtherTool(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<RadioGroupLabel htmlFor={`radio-${tool}`}>{label}</RadioGroupLabel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tool}
|
||||
className="checkbox-item observability-tool-checkbox-item"
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${tool}`}
|
||||
checked={observabilityTool === tool}
|
||||
onCheckedChange={createObservabilityToolHandler(tool)}
|
||||
labelName={label}
|
||||
/>
|
||||
<div key={tool} className="radio-item observability-tool-radio-item">
|
||||
<RadioGroupItem value={tool} id={`radio-${tool}`} />
|
||||
<RadioGroupLabel htmlFor={`radio-${tool}`}>{label}</RadioGroupLabel>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{showMigrationQuestion && (
|
||||
<div className="form-group">
|
||||
<div className="question">
|
||||
What is your timeline for migrating to SigNoz?
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={migrationTimeline || ''}
|
||||
onValueChange={setMigrationTimeline}
|
||||
className="migration-timeline-radio-container"
|
||||
>
|
||||
{Object.entries(migrationTimelineOptions).map(([key, label]) => (
|
||||
<div key={key} className="radio-item migration-timeline-radio-item">
|
||||
<RadioGroupItem value={key} id={`radio-migration-${key}`} />
|
||||
<RadioGroupLabel htmlFor={`radio-migration-${key}`}>
|
||||
{label}
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="question">Do you already use OpenTelemetry?</div>
|
||||
<div className="opentelemetry-radio-container">
|
||||
<Radio.Group
|
||||
value={((): string | undefined => {
|
||||
if (usesOtel === true) {
|
||||
return 'yes';
|
||||
}
|
||||
if (usesOtel === false) {
|
||||
return 'no';
|
||||
}
|
||||
return undefined;
|
||||
})()}
|
||||
onChange={(e: RadioChangeEvent): void =>
|
||||
handleOtelChange(e.target.value)
|
||||
}
|
||||
className="opentelemetry-radio-group"
|
||||
>
|
||||
<div className="opentelemetry-radio-items-wrapper">
|
||||
<Radio value="yes" className="opentelemetry-radio-item">
|
||||
Yes
|
||||
</Radio>
|
||||
<Radio value="no" className="opentelemetry-radio-item">
|
||||
No
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={usesOtel === true ? 'yes' : usesOtel === false ? 'no' : ''}
|
||||
onValueChange={handleOtelChange}
|
||||
className="opentelemetry-radio-container"
|
||||
>
|
||||
<div className="radio-item opentelemetry-radio-item">
|
||||
<RadioGroupItem value="yes" id="radio-otel-yes" />
|
||||
<RadioGroupLabel htmlFor="radio-otel-yes">Yes</RadioGroupLabel>
|
||||
</div>
|
||||
<div className="radio-item opentelemetry-radio-item">
|
||||
<RadioGroupItem value="no" id="radio-otel-no" />
|
||||
<RadioGroupLabel htmlFor="radio-otel-no">No</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,15 +233,9 @@ function OrgQuestions({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${isNextDisabled ? 'disabled' : ''}`}
|
||||
onClick={handleOnNext}
|
||||
onClick={handleNext}
|
||||
disabled={isNextDisabled}
|
||||
suffixIcon={
|
||||
isLoading ? (
|
||||
<Loader2 className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
expect(screen.getByText(/welcome to signoz cloud/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name of your company/i)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(/which observability tool do you currently use/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -86,15 +86,12 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
const datadogCheckbox = screen.getByLabelText(/datadog/i);
|
||||
await user.click(datadogCheckbox);
|
||||
|
||||
const otelYes = screen.getByRole('radio', { name: /yes/i });
|
||||
await user.click(otelYes);
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
|
||||
const nextButton = await screen.findByRole('button', { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
@@ -112,15 +109,38 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows migration timeline options only when specific observability tools are selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Initially not visible
|
||||
expect(
|
||||
screen.queryByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const datadogCheckbox = screen.getByLabelText(/datadog/i);
|
||||
await user.click(datadogCheckbox);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Not visible when None is selected
|
||||
const noneCheckbox = screen.getByLabelText(/none\/starting fresh/i);
|
||||
await user.click(noneCheckbox);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/What is your timeline for migrating to SigNoz/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('proceeds to step 2 when next is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
@@ -137,11 +157,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -157,11 +176,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -175,11 +193,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -203,11 +220,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -232,11 +248,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate through steps 1 and 2
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -267,11 +282,10 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
// Navigate to step 3
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
await user.click(screen.getByLabelText(/just exploring/i));
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
expect(
|
||||
@@ -290,40 +304,4 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles organization update error gracefully', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(EDIT_ORG_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update organization',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<OnboardingQuestionaire />);
|
||||
|
||||
const orgNameInput = screen.getByLabelText(/name of your company/i);
|
||||
await user.clear(orgNameInput);
|
||||
await user.type(orgNameInput, 'Test Company');
|
||||
await user.click(screen.getByLabelText(/datadog/i));
|
||||
await user.click(screen.getByRole('radio', { name: /yes/i }));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
|
||||
// Component should still be functional
|
||||
await waitFor(() => {
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import InviteTeamMembers from './InviteTeamMembers/InviteTeamMembers';
|
||||
import OptimiseSignozNeeds, {
|
||||
OptimiseSignozDetails,
|
||||
} from './OptimiseSignozNeeds/OptimiseSignozNeeds';
|
||||
import OrgQuestions, { OrgData, OrgDetails } from './OrgQuestions/OrgQuestions';
|
||||
import OrgQuestions, { OrgDetails } from './OrgQuestions/OrgQuestions';
|
||||
|
||||
import './OnboardingQuestionaire.styles.scss';
|
||||
|
||||
@@ -37,11 +37,11 @@ export const showErrorNotification = (
|
||||
};
|
||||
|
||||
const INITIAL_ORG_DETAILS: OrgDetails = {
|
||||
organisationName: '',
|
||||
usesObservability: true,
|
||||
observabilityTool: '',
|
||||
otherTool: '',
|
||||
usesOtel: null,
|
||||
migrationTimeline: null,
|
||||
};
|
||||
|
||||
const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
|
||||
@@ -79,25 +79,11 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
InviteTeamMembersProps[] | null
|
||||
>(null);
|
||||
|
||||
const [currentOrgData, setCurrentOrgData] = useState<OrgData | null>(null);
|
||||
|
||||
const [
|
||||
updatingOrgOnboardingStatus,
|
||||
setUpdatingOrgOnboardingStatus,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (org) {
|
||||
setCurrentOrgData(org[0]);
|
||||
|
||||
setOrgDetails({
|
||||
...orgDetails,
|
||||
organisationName: org[0].displayName,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [org]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Org Onboarding: Started', {
|
||||
org_id: org?.[0]?.id,
|
||||
@@ -175,6 +161,7 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
? (orgDetails?.otherTool as string)
|
||||
: (orgDetails?.observabilityTool as string),
|
||||
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
|
||||
timeline_for_migrating_to_signoz: orgDetails?.migrationTimeline as string,
|
||||
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
|
||||
'Others',
|
||||
)
|
||||
@@ -208,7 +195,6 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
<div className="onboarding-questionaire-content">
|
||||
{currentStep === 1 && (
|
||||
<OrgQuestions
|
||||
currentOrgData={currentOrgData}
|
||||
orgDetails={{
|
||||
...orgDetails,
|
||||
usesOtel: orgDetails.usesOtel ?? null,
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.auth-domain-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +21,36 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
|
||||
.auth-domain-list-action-link {
|
||||
cursor: pointer;
|
||||
color: var(--primary);
|
||||
transition: color 0.3s;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.delete {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-domain-list-na {
|
||||
padding-left: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.delete-ingestion-key-modal {
|
||||
.delete-text {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Modal } from 'antd';
|
||||
import put from 'api/v1/domains/id/put';
|
||||
import post from 'api/v1/domains/post';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Form, Modal } from 'antd';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import {
|
||||
useCreateAuthDomain,
|
||||
useUpdateAuthDomain,
|
||||
} from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
AuthtypesGoogleConfigDTO,
|
||||
AuthtypesRoleMappingDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
import AuthnProviderSelector from './AuthnProviderSelector';
|
||||
import {
|
||||
convertDomainMappingsToRecord,
|
||||
convertGroupMappingsToRecord,
|
||||
FormValues,
|
||||
prepareInitialValues,
|
||||
} from './CreateEdit.utils';
|
||||
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
|
||||
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
|
||||
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
|
||||
|
||||
import './CreateEdit.styles.scss';
|
||||
|
||||
interface CreateOrEditProps {
|
||||
isCreate: boolean;
|
||||
onClose: () => void;
|
||||
record?: GettableAuthDomain;
|
||||
}
|
||||
|
||||
function configureAuthnProvider(
|
||||
authnProvider: string,
|
||||
isCreate: boolean,
|
||||
@@ -39,64 +49,186 @@ function configureAuthnProvider(
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateOrEditProps {
|
||||
isCreate: boolean;
|
||||
onClose: () => void;
|
||||
record?: AuthtypesGettableAuthDomainDTO;
|
||||
}
|
||||
|
||||
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const { isCreate, record, onClose } = props;
|
||||
const [form] = Form.useForm<PostableAuthDomain>();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [authnProvider, setAuthnProvider] = useState<string>(
|
||||
record?.ssoType || '',
|
||||
);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: AxiosError<RenderErrorResponseDTO>): void => {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
showErrorModal(apiError as APIError);
|
||||
}
|
||||
},
|
||||
[showErrorModal],
|
||||
);
|
||||
const samlEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
|
||||
|
||||
const onSubmitHandler = async (): Promise<void> => {
|
||||
const {
|
||||
mutate: createAuthDomain,
|
||||
isLoading: isCreating,
|
||||
} = useCreateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const {
|
||||
mutate: updateAuthDomain,
|
||||
isLoading: isUpdating,
|
||||
} = useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
/**
|
||||
* Prepares Google Auth config for API payload
|
||||
*/
|
||||
const getGoogleAuthConfig = useCallback(():
|
||||
| AuthtypesGoogleConfigDTO
|
||||
| undefined => {
|
||||
const config = form.getFieldValue('googleAuthConfig');
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { domainToAdminEmailList, ...rest } = config;
|
||||
const domainToAdminEmail = convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(domainToAdminEmail && { domainToAdminEmail }),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
// Prepares role mapping for API payload
|
||||
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
|
||||
const roleMapping = form.getFieldValue('roleMapping');
|
||||
if (!roleMapping) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { groupMappingsList, ...rest } = roleMapping;
|
||||
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
|
||||
|
||||
// Only return roleMapping if there's meaningful content
|
||||
const hasDefaultRole = !!rest.defaultRole;
|
||||
const hasUseRoleAttribute = rest.useRoleAttribute === true;
|
||||
const hasGroupMappings =
|
||||
groupMappings && Object.keys(groupMappings).length > 0;
|
||||
|
||||
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(groupMappings && { groupMappings }),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const onSubmitHandler = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = form.getFieldValue('name');
|
||||
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
|
||||
const googleAuthConfig = getGoogleAuthConfig();
|
||||
const samlConfig = form.getFieldValue('samlConfig');
|
||||
const oidcConfig = form.getFieldValue('oidcConfig');
|
||||
const roleMapping = getRoleMapping();
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
await post({
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
if (isCreate) {
|
||||
createAuthDomain(
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await put({
|
||||
id: record?.id || '',
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Domain created successfully');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!record?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
updateAuthDomain(
|
||||
{
|
||||
pathParams: { id: record.id },
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Domain updated successfully');
|
||||
onClose();
|
||||
},
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
authnProvider,
|
||||
createAuthDomain,
|
||||
form,
|
||||
getGoogleAuthConfig,
|
||||
getRoleMapping,
|
||||
handleError,
|
||||
isCreate,
|
||||
|
||||
const onBackHandler = (): void => {
|
||||
onClose,
|
||||
record,
|
||||
updateAuthDomain,
|
||||
]);
|
||||
|
||||
const onBackHandler = useCallback((): void => {
|
||||
form.resetFields();
|
||||
setAuthnProvider('');
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Modal open footer={null} onCancel={onClose}>
|
||||
<Modal
|
||||
open
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
width={authnProvider ? 980 : undefined}
|
||||
>
|
||||
<Form
|
||||
name="auth-domain"
|
||||
initialValues={defaultTo(record, {
|
||||
initialValues={defaultTo(prepareInitialValues(record), {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
@@ -114,9 +246,22 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
<div className="auth-domain-configure">
|
||||
{configureAuthnProvider(authnProvider, isCreate)}
|
||||
<section className="action-buttons">
|
||||
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
|
||||
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
|
||||
<Button onClick={onSubmitHandler} type="primary">
|
||||
{isCreate && (
|
||||
<Button onClick={onBackHandler} variant="solid" color="secondary">
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{!isCreate && (
|
||||
<Button onClick={onClose} variant="solid" color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSubmitHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isCreating || isUpdating}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
AuthtypesGoogleConfigDTO,
|
||||
AuthtypesOIDCConfigDTO,
|
||||
AuthtypesRoleMappingDTO,
|
||||
AuthtypesSamlConfigDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Form values interface for internal use (includes array-based fields for UI)
|
||||
export interface FormValues {
|
||||
name?: string;
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: string;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
|
||||
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
|
||||
};
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO & {
|
||||
groupMappingsList?: Array<{ groupName?: string; role?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts groupMappingsList array to groupMappings Record for API
|
||||
*/
|
||||
export function convertGroupMappingsToRecord(
|
||||
groupMappingsList?: Array<{ groupName?: string; role?: string }>,
|
||||
): Record<string, string> | undefined {
|
||||
if (!Array.isArray(groupMappingsList) || groupMappingsList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const groupMappings: Record<string, string> = {};
|
||||
groupMappingsList.forEach((item) => {
|
||||
if (item.groupName && item.role) {
|
||||
groupMappings[item.groupName] = item.role;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(groupMappings).length > 0 ? groupMappings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts groupMappings Record to groupMappingsList array for form
|
||||
*/
|
||||
export function convertGroupMappingsToList(
|
||||
groupMappings?: Record<string, string> | null,
|
||||
): Array<{ groupName: string; role: string }> {
|
||||
if (!groupMappings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(groupMappings).map(([groupName, role]) => ({
|
||||
groupName,
|
||||
role,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domainToAdminEmailList array to domainToAdminEmail Record for API
|
||||
*/
|
||||
export function convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>,
|
||||
): Record<string, string> | undefined {
|
||||
if (
|
||||
!Array.isArray(domainToAdminEmailList) ||
|
||||
domainToAdminEmailList.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const domainToAdminEmail: Record<string, string> = {};
|
||||
domainToAdminEmailList.forEach((item) => {
|
||||
if (item.domain && item.adminEmail) {
|
||||
domainToAdminEmail[item.domain] = item.adminEmail;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(domainToAdminEmail).length > 0
|
||||
? domainToAdminEmail
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domainToAdminEmail Record to domainToAdminEmailList array for form
|
||||
*/
|
||||
export function convertDomainMappingsToList(
|
||||
domainToAdminEmail?: Record<string, string>,
|
||||
): Array<{ domain: string; adminEmail: string }> {
|
||||
if (!domainToAdminEmail) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
|
||||
domain,
|
||||
adminEmail,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares initial form values from API record
|
||||
*/
|
||||
export function prepareInitialValues(
|
||||
record?: AuthtypesGettableAuthDomainDTO,
|
||||
): FormValues {
|
||||
if (!record) {
|
||||
return {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
googleAuthConfig: record.googleAuthConfig
|
||||
? {
|
||||
...record.googleAuthConfig,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
record.googleAuthConfig.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: record.roleMapping
|
||||
? {
|
||||
...record.roleMapping,
|
||||
groupMappingsList: convertGroupMappingsToList(
|
||||
record.roleMapping.groupMappings,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,67 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Color, Style } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import DomainMappingList from './components/DomainMappingList';
|
||||
import EmailTagInput from './components/EmailTagInput';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'workspace-groups' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureGoogleAuthAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups'], form);
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleWorkspaceGroupsChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const isExpanding = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
setExpandedSection(isExpanding ? 'workspace-groups' : null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
hasErrors: hasWorkspaceGroupsErrors,
|
||||
errorMessages: workspaceGroupsErrorMessages,
|
||||
} = useCollapseSectionErrors(
|
||||
['googleAuthConfig'],
|
||||
[
|
||||
['googleAuthConfig', 'fetchGroups'],
|
||||
['googleAuthConfig', 'serviceAccountJson'],
|
||||
['googleAuthConfig', 'domainToAdminEmailList'],
|
||||
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
|
||||
['googleAuthConfig', 'allowedGroups'],
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="google-auth">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit Google Authentication
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph className="description">
|
||||
<div className="authn-provider">
|
||||
<section className="authn-provider__header">
|
||||
<h3 className="authn-provider__title">Edit Google Authentication</h3>
|
||||
<p className="authn-provider__description">
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
@@ -25,50 +72,247 @@ function ConfigureGoogleAuthAuthnProvider({
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Typography.Paragraph>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__columns">
|
||||
{/* Left Column - Core OAuth Settings */}
|
||||
<div className="authn-provider__left">
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="google-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="google-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="google-client-id">
|
||||
Client ID
|
||||
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Client ID is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="google-client-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `It is the application's secret.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="google-client-secret">
|
||||
Client Secret
|
||||
<Tooltip title="It is the application's secret.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Client Secret is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="google-client-secret" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-skip-email-verification"
|
||||
labelName="Skip Email Verification"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['googleAuthConfig', 'insecureSkipEmailVerified'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Google Workspace Groups (Advanced) */}
|
||||
<div className="authn-provider__right">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={
|
||||
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
|
||||
}
|
||||
onChange={handleWorkspaceGroupsChange}
|
||||
className="authn-provider__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="workspace-groups"
|
||||
header={
|
||||
<div className="authn-provider__collapse-header">
|
||||
{expandedSection !== 'workspace-groups' ? (
|
||||
<ChevronRight size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
<div className="authn-provider__collapse-header-text">
|
||||
<h4 className="authn-provider__section-title">
|
||||
Google Workspace Groups (Advanced)
|
||||
</h4>
|
||||
<p className="authn-provider__section-description">
|
||||
Enable group fetching to retrieve user groups from Google Workspace.
|
||||
Requires a Service Account with domain-wide delegation.
|
||||
</p>
|
||||
</div>
|
||||
{expandedSection !== 'workspace-groups' && hasWorkspaceGroupsErrors && (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
{workspaceGroupsErrorMessages.map((msg) => (
|
||||
<div key={msg}>{msg}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="authn-provider__group-content">
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'fetchGroups']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-fetch-groups"
|
||||
labelName="Fetch Groups"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Enable fetching Google Workspace groups for the user. Requires service account configuration.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{fetchGroups && (
|
||||
<div className="authn-provider__group-fields">
|
||||
<div className="authn-provider__field-group">
|
||||
<label
|
||||
className="authn-provider__label"
|
||||
htmlFor="google-service-account-json"
|
||||
>
|
||||
Service Account JSON
|
||||
<Tooltip title="The JSON content of the Google Service Account credentials file. Required for group fetching.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'serviceAccountJson']}
|
||||
className="authn-provider__form-item"
|
||||
>
|
||||
<TextArea
|
||||
id="google-service-account-json"
|
||||
rows={3}
|
||||
placeholder="Paste service account JSON"
|
||||
className="authn-provider__textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<DomainMappingList
|
||||
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
|
||||
/>
|
||||
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-transitive-membership"
|
||||
labelName="Fetch Transitive Group Membership"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, recursively fetch groups that contain other groups (transitive membership).">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="authn-provider__field-group">
|
||||
<label
|
||||
className="authn-provider__label"
|
||||
htmlFor="google-allowed-groups"
|
||||
>
|
||||
Allowed Groups
|
||||
<Tooltip title="Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'allowedGroups']}
|
||||
className="authn-provider__form-item"
|
||||
>
|
||||
<EmailTagInput placeholder="Type a group email and press Enter" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,212 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
|
||||
import ClaimMappingSection from './components/ClaimMappingSection';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'claim-mapping' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureOIDCAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleClaimMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'claim-mapping' : null);
|
||||
}, []);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit OIDC Authentication
|
||||
</Typography.Text>
|
||||
<div className="authn-provider">
|
||||
<section className="authn-provider__header">
|
||||
<h3 className="authn-provider__title">Edit OIDC Authentication</h3>
|
||||
<p className="authn-provider__description">
|
||||
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__columns">
|
||||
{/* Left Column - Core OIDC Settings */}
|
||||
<div className="authn-provider__left">
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="oidc-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer URL"
|
||||
name={['oidcConfig', 'issuer']}
|
||||
tooltip={{
|
||||
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="oidc-issuer">
|
||||
Issuer URL
|
||||
<Tooltip title='The URL identifier for the OIDC provider. For example: "https://accounts.google.com" or "https://login.salesforce.com".'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuer']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Issuer URL is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-issuer" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer Alias"
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
tooltip={{
|
||||
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
|
||||
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="oidc-issuer-alias">
|
||||
Issuer Alias
|
||||
<Tooltip title="Optional: Override the issuer URL from .well-known/openid-configuration for providers like Azure or Oracle IDCS.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
className="authn-provider__form-item"
|
||||
>
|
||||
<Input id="oidc-issuer-alias" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['oidcConfig', 'clientId']}
|
||||
tooltip={{ title: `It is the application's ID.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="oidc-client-id">
|
||||
Client ID
|
||||
<Tooltip title="The application's client ID from your OIDC provider.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientId']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Client ID is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-client-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
tooltip={{ title: `It is the application's secret.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="oidc-client-secret">
|
||||
Client Secret
|
||||
<Tooltip title="The application's client secret from your OIDC provider.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Client Secret is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-client-secret" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Email Claim Mapping"
|
||||
name={['oidcConfig', 'claimMapping', 'email']}
|
||||
tooltip={{
|
||||
title: `Mapping of email claims to the corresponding email field in the token.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="oidc-skip-email-verification"
|
||||
labelName="Skip Email Verification"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['oidcConfig', 'insecureSkipEmailVerified'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Email Verification"
|
||||
name={['oidcConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip email verification. Defaults to "false"`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'getUserInfo']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="oidc-get-user-info"
|
||||
labelName="Get User Info"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(['oidcConfig', 'getUserInfo'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Use the userinfo endpoint to get additional claims. Useful when providers return thin ID tokens.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Get User Info"
|
||||
name={['oidcConfig', 'getUserInfo']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="OIDC won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="OIDC won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
{/* Right Column - Advanced Settings */}
|
||||
<div className="authn-provider__right">
|
||||
<ClaimMappingSection
|
||||
fieldNamePrefix={['oidcConfig', 'claimMapping']}
|
||||
isExpanded={expandedSection === 'claim-mapping'}
|
||||
onExpandChange={handleClaimMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,191 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
|
||||
import AttributeMappingSection from './components/AttributeMappingSection';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'attribute-mapping' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureSAMLAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleAttributeMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'attribute-mapping' : null);
|
||||
}, []);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit SAML Authentication
|
||||
</Typography.Text>
|
||||
<div className="authn-provider">
|
||||
<section className="authn-provider__header">
|
||||
<h3 className="authn-provider__title">Edit SAML Authentication</h3>
|
||||
<p className="authn-provider__description">
|
||||
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__columns">
|
||||
{/* Left Column - Core SAML Settings */}
|
||||
<div className="authn-provider__left">
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="saml-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="saml-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML ACS URL"
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
tooltip={{
|
||||
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="saml-acs-url">
|
||||
SAML ACS URL
|
||||
<Tooltip title="The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML ACS URL is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="saml-acs-url" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML Entity ID"
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
tooltip={{
|
||||
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="saml-entity-id">
|
||||
SAML Entity ID
|
||||
<Tooltip title="The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML Entity ID is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="saml-entity-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML X.509 Certificate"
|
||||
name={['samlConfig', 'samlCert']}
|
||||
tooltip={{
|
||||
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
|
||||
}}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__field-group">
|
||||
<label className="authn-provider__label" htmlFor="saml-certificate">
|
||||
SAML X.509 Certificate
|
||||
<Tooltip title="The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlCert']}
|
||||
className="authn-provider__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML Certificate is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
id="saml-certificate"
|
||||
rows={3}
|
||||
placeholder="Paste X.509 certificate"
|
||||
className="authn-provider__textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Signing AuthN Requests"
|
||||
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div className="authn-provider__checkbox-row">
|
||||
<Form.Item
|
||||
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="saml-skip-signing"
|
||||
labelName="Skip Signing AuthN Requests"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['samlConfig', 'insecureSkipAuthNRequestsSigned'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Whether to skip signing the SAML requests. For providers like JumpCloud, this should be enabled.">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Advanced Settings */}
|
||||
<div className="authn-provider__right">
|
||||
<AttributeMappingSection
|
||||
fieldNamePrefix={['samlConfig', 'attributeMapping']}
|
||||
isExpanded={expandedSection === 'attribute-mapping'}
|
||||
onExpandChange={handleAttributeMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,240 @@
|
||||
.google-auth {
|
||||
.authn-provider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px !important;
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__right {
|
||||
border-left: 1px solid var(--l3-border);
|
||||
padding-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0px !important;
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
}
|
||||
&__textarea {
|
||||
min-height: 60px !important;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
button[role='checkbox'] {
|
||||
border: 1px solid var(--l2-foreground) !important;
|
||||
border-radius: 2px;
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__group-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
.authn-provider__field-group,
|
||||
.authn-provider__checkbox-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
.callout {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
.attribute-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Color, Style } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './AttributeMappingSection.styles.scss';
|
||||
|
||||
interface AttributeMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function AttributeMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: AttributeMappingSectionProps): JSX.Element {
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['attribute-mapping'] : [];
|
||||
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
|
||||
|
||||
return (
|
||||
<div className="attribute-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="attribute-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="attribute-mapping"
|
||||
header={
|
||||
<div
|
||||
className="attribute-mapping-section__collapse-header"
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="attribute-mapping-content"
|
||||
>
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="attribute-mapping-section__collapse-header-text">
|
||||
<h4 className="attribute-mapping-section__section-title">
|
||||
Attribute Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="attribute-mapping-section__section-description">
|
||||
Configure how SAML assertion attributes from your Identity Provider map
|
||||
to SigNoz user attributes. Leave empty to use default values.
|
||||
</p>
|
||||
</div>
|
||||
{!expanded && hasErrors && (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{errorMessages.map((msg) => (
|
||||
<div key={msg}>{msg}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
id="attribute-mapping-content"
|
||||
className="attribute-mapping-section__content"
|
||||
>
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="email-attribute"
|
||||
>
|
||||
Email Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's email. Default: 'email'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'email']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="email-attribute" placeholder="Email" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Name Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="name-attribute"
|
||||
>
|
||||
Name Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'name']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="name-attribute" placeholder="Name" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Groups Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="groups-attribute"
|
||||
>
|
||||
Groups Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'groups']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="groups-attribute" placeholder="Groups" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Role Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="role-attribute"
|
||||
>
|
||||
Role Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's role directly from the IDP. Default: 'role'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'role']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="role-attribute" placeholder="Role" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingSection;
|
||||
@@ -0,0 +1,128 @@
|
||||
.claim-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Color, Style } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './ClaimMappingSection.styles.scss';
|
||||
|
||||
interface ClaimMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function ClaimMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: ClaimMappingSectionProps): JSX.Element {
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['claim-mapping'] : [];
|
||||
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
|
||||
|
||||
return (
|
||||
<div className="claim-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="claim-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="claim-mapping"
|
||||
header={
|
||||
<div
|
||||
className="claim-mapping-section__collapse-header"
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="claim-mapping-content"
|
||||
>
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="claim-mapping-section__collapse-header-text">
|
||||
<h4 className="claim-mapping-section__section-title">
|
||||
Claim Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="claim-mapping-section__section-description">
|
||||
Configure how claims from your Identity Provider map to SigNoz user
|
||||
attributes. Leave empty to use default values.
|
||||
</p>
|
||||
</div>
|
||||
{!expanded && hasErrors && (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{errorMessages.map((msg) => (
|
||||
<div key={msg}>{msg}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div id="claim-mapping-content" className="claim-mapping-section__content">
|
||||
{/* Email Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="email-claim">
|
||||
Email Claim
|
||||
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'email']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="email-claim" placeholder="Email" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Name Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="name-claim">
|
||||
Name Claim
|
||||
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'name']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="name-claim" placeholder="Name" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Groups Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="groups-claim">
|
||||
Groups Claim
|
||||
<Tooltip title="The claim key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'groups']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="groups-claim" placeholder="Groups" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Role Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="role-claim">
|
||||
Role Claim
|
||||
<Tooltip title="The claim key that contains the user's role directly from the IDP. Default: 'role'">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'role']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="role-claim" placeholder="Role" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimMappingSection;
|
||||
@@ -0,0 +1,103 @@
|
||||
.domain-mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
background: transparent !important;
|
||||
color: var(--destructive) !important;
|
||||
opacity: 0.6 !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
color: var(--destructive) !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: var(--destructive) !important;
|
||||
|
||||
svg {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7 !important;
|
||||
background: rgba(229, 72, 77, 0.15) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
width: 100%;
|
||||
|
||||
// Ensure icon is visible
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l2-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import './DomainMappingList.styles.scss';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const validateEmail = (_: unknown, value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error('Admin email is required'));
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value)) {
|
||||
return Promise.reject(new Error('Please enter a valid email'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
interface DomainMappingListProps {
|
||||
fieldNamePrefix: string[];
|
||||
}
|
||||
|
||||
function DomainMappingList({
|
||||
fieldNamePrefix,
|
||||
}: DomainMappingListProps): JSX.Element {
|
||||
return (
|
||||
<div className="domain-mapping-list">
|
||||
<div className="domain-mapping-list__header">
|
||||
<span className="domain-mapping-list__title">
|
||||
Domain to Admin Email Mapping
|
||||
</span>
|
||||
<p className="domain-mapping-list__description">
|
||||
Map workspace domains to admin emails for service account impersonation.
|
||||
Use "*" as a wildcard for any domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.List name={fieldNamePrefix}>
|
||||
{(fields, { add, remove }): JSX.Element => (
|
||||
<div className="domain-mapping-list__items">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="domain-mapping-list__row">
|
||||
<Form.Item
|
||||
name={[field.name, 'domain']}
|
||||
className="domain-mapping-list__field"
|
||||
rules={[{ required: true, message: 'Domain is required' }]}
|
||||
>
|
||||
<Input placeholder="Domain (e.g., example.com or *)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[field.name, 'adminEmail']}
|
||||
className="domain-mapping-list__field"
|
||||
rules={[{ validator: validateEmail }]}
|
||||
>
|
||||
<Input placeholder="Admin Email" />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="domain-mapping-list__remove-btn"
|
||||
onClick={(): void => remove(field.name)}
|
||||
aria-label="Remove mapping"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
onClick={(): void => add({ domain: '', adminEmail: '' })}
|
||||
prefixIcon={<Plus size={14} />}
|
||||
className="domain-mapping-list__add-btn"
|
||||
>
|
||||
Add Domain Mapping
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DomainMappingList;
|
||||
@@ -0,0 +1,28 @@
|
||||
.email-tag-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-search {
|
||||
input {
|
||||
height: 32px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
|
||||
import './EmailTagInput.styles.scss';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface EmailTagInputProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function EmailTagInput({
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Type an email and press Enter',
|
||||
}: EmailTagInputProps): JSX.Element {
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: string[]): void => {
|
||||
const addedValues = newValues.filter((v) => !value.includes(v));
|
||||
const invalidEmail = addedValues.find((v) => !EMAIL_REGEX.test(v));
|
||||
|
||||
if (invalidEmail) {
|
||||
setValidationError(`"${invalidEmail}" is not a valid email`);
|
||||
return;
|
||||
}
|
||||
setValidationError('');
|
||||
onChange?.(newValues);
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="email-tag-input">
|
||||
<Tooltip
|
||||
title={validationError}
|
||||
open={!!validationError}
|
||||
placement="topRight"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
tokenSeparators={[',', ' ']}
|
||||
className="email-tag-input__select"
|
||||
allowClear
|
||||
status={validationError ? 'error' : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmailTagInput;
|
||||
@@ -0,0 +1,288 @@
|
||||
.role-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 24px;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__group-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__group-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__group-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
&--group {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
background: transparent !important;
|
||||
color: var(--destructive) !important;
|
||||
opacity: 0.6 !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
color: var(--destructive) !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: var(--destructive) !important;
|
||||
|
||||
svg {
|
||||
color: var(--destructive) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7 !important;
|
||||
background: rgba(229, 72, 77, 0.15) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
width: 100%;
|
||||
|
||||
// Ensure icon is visible
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l2-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Checkbox border visibility ---
|
||||
button[role='checkbox'] {
|
||||
border: 1px solid var(--l2-foreground) !important;
|
||||
border-radius: 2px;
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Color, Style } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Plus,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: RoleMappingSectionProps): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
const useRoleAttribute = Form.useWatch(
|
||||
[...fieldNamePrefix, 'useRoleAttribute'],
|
||||
form,
|
||||
);
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['role-mapping'] : [];
|
||||
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
|
||||
|
||||
return (
|
||||
<div className="role-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="role-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="role-mapping"
|
||||
header={
|
||||
<div
|
||||
className="role-mapping-section__collapse-header"
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="role-mapping-content"
|
||||
>
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="role-mapping-section__collapse-header-text">
|
||||
<h4 className="role-mapping-section__section-title">
|
||||
Role Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="role-mapping-section__section-description">
|
||||
Configure how user roles are determined from your Identity Provider.
|
||||
You can either use a direct role attribute or map IDP groups to SigNoz
|
||||
roles.
|
||||
</p>
|
||||
</div>
|
||||
{!expanded && hasErrors && (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{errorMessages.map((msg) => (
|
||||
<div key={msg}>{msg}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div id="role-mapping-content" className="role-mapping-section__content">
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label className="role-mapping-section__label" htmlFor="default-role">
|
||||
Default Role
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'defaultRole']}
|
||||
className="role-mapping-section__form-item"
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<Select
|
||||
id="default-role"
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="role-mapping-section__checkbox-row">
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'useRoleAttribute']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="use-role-attribute"
|
||||
labelName="Use Role Attribute Directly"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!useRoleAttribute && (
|
||||
<div className="role-mapping-section__group-mappings">
|
||||
<div className="role-mapping-section__group-header">
|
||||
<span className="role-mapping-section__group-title">
|
||||
Group to Role Mappings
|
||||
</span>
|
||||
<p className="role-mapping-section__group-description">
|
||||
Map IDP group names to SigNoz roles. If a user belongs to multiple
|
||||
groups, the highest privilege role will be assigned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.List name={[...fieldNamePrefix, 'groupMappingsList']}>
|
||||
{(fields, { add, remove }): JSX.Element => (
|
||||
<div className="role-mapping-section__items">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="role-mapping-section__row">
|
||||
<Form.Item
|
||||
name={[field.name, 'groupName']}
|
||||
className="role-mapping-section__field role-mapping-section__field--group"
|
||||
rules={[{ required: true, message: 'Group name is required' }]}
|
||||
>
|
||||
<Input placeholder="IDP Group Name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[field.name, 'role']}
|
||||
className="role-mapping-section__field role-mapping-section__field--role"
|
||||
rules={[{ required: true, message: 'Role is required' }]}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<Select
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="role-mapping-section__remove-btn"
|
||||
onClick={(): void => remove(field.name)}
|
||||
aria-label="Remove mapping"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
|
||||
prefixIcon={<Plus size={14} />}
|
||||
className="role-mapping-section__add-btn"
|
||||
>
|
||||
Add Group Mapping
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleMappingSection;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch } from '@signozhq/switch';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface SSOEnforcementToggleProps {
|
||||
isDefaultChecked: boolean;
|
||||
record: AuthtypesGettableAuthDomainDTO;
|
||||
}
|
||||
|
||||
function SSOEnforcementToggle({
|
||||
isDefaultChecked,
|
||||
record,
|
||||
}: SSOEnforcementToggleProps): JSX.Element {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(isDefaultChecked);
|
||||
}, [isDefaultChecked]);
|
||||
|
||||
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>();
|
||||
|
||||
const onChangeHandler = (checked: boolean): void => {
|
||||
if (!record.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecked(checked);
|
||||
|
||||
updateAuthDomain(
|
||||
{
|
||||
pathParams: { id: record.id },
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
roleMapping: record.roleMapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
setIsChecked(!checked);
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
showErrorModal(apiError as APIError);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
disabled={isLoading}
|
||||
checked={isChecked}
|
||||
onCheckedChange={onChangeHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SSOEnforcementToggle;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user