mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-18 06:52:34 +00:00
Compare commits
20 Commits
SIG-3497
...
feat/root-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf1384892 | ||
|
|
5aa655f875 | ||
|
|
ffde89d905 | ||
|
|
ff732c6d6c | ||
|
|
30d902f92d | ||
|
|
6a9a8b14b3 | ||
|
|
4e7d2f8fb8 | ||
|
|
bbc7f7bb3d | ||
|
|
c3294cf704 | ||
|
|
e94b2a66d7 | ||
|
|
ceca9fbd42 | ||
|
|
14cec7b465 | ||
|
|
04753e2d57 | ||
|
|
85ba9a6840 | ||
|
|
230b5ab7b7 | ||
|
|
93c7c7fc93 | ||
|
|
3011c24662 | ||
|
|
559631f217 | ||
|
|
80bcc8971f | ||
|
|
d8e3134729 |
@@ -4,6 +4,7 @@ services:
|
||||
container_name: signoz-otel-collector-dev
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
environment:
|
||||
|
||||
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -43,12 +43,6 @@
|
||||
/pkg/analytics/ @vikrantgupta25
|
||||
/pkg/statsreporter/ @vikrantgupta25
|
||||
|
||||
# Emailing Owners
|
||||
|
||||
/pkg/emailing/ @vikrantgupta25
|
||||
/pkg/types/emailtypes/ @vikrantgupta25
|
||||
/templates/email/ @vikrantgupta25
|
||||
|
||||
# Querier Owners
|
||||
|
||||
/pkg/querier/ @srikanthccv
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -14,8 +14,5 @@
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
2
Makefile
2
Makefile
@@ -238,4 +238,4 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
@mockery --config .mockery.yml
|
||||
@mockery --config .mockery.yml
|
||||
|
||||
@@ -176,6 +176,25 @@ Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen S
|
||||
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
|
||||
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
|
||||
|
||||
### Unsere Projektbetreuer
|
||||
|
||||
#### Backend
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
|
||||
#### Frontend
|
||||
|
||||
- [Palash Gupta](https://github.com/palashgdev)
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Rajat Dabade](https://github.com/Rajat-Dabade)
|
||||
|
||||
#### DevOps
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
|
||||
<br /><br />
|
||||
|
||||
## Dokumentation
|
||||
|
||||
28
README.md
28
README.md
@@ -221,6 +221,34 @@ We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING
|
||||
|
||||
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
|
||||
|
||||
### Project maintainers
|
||||
|
||||
#### Backend
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
- [Shivanshu Raj Shrivastava](https://github.com/shivanshuraj1333)
|
||||
- [Ekansh Gupta](https://github.com/eKuG)
|
||||
- [Aniket Agarwal](https://github.com/aniketio-ctrl)
|
||||
|
||||
#### Frontend
|
||||
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Vikrant Gupta](https://github.com/vikrantgupta25)
|
||||
- [Sagar Rajput](https://github.com/SagarRajput-7)
|
||||
- [Shaheer Kochai](https://github.com/ahmadshaheer)
|
||||
- [Amlan Kumar Nandy](https://github.com/amlannandy)
|
||||
- [Sahil Khan](https://github.com/sawhil)
|
||||
- [Aditya Singh](https://github.com/aks07)
|
||||
- [Abhi Kumar](https://github.com/ahrefabhi)
|
||||
|
||||
#### DevOps
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
- [Vibhu Pandey](https://github.com/therealpandey)
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
|
||||
@@ -187,6 +187,25 @@ Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metric
|
||||
|
||||
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
|
||||
|
||||
### 项目维护人员
|
||||
|
||||
#### 后端
|
||||
|
||||
- [Ankit Nayan](https://github.com/ankitnayan)
|
||||
- [Nityananda Gohain](https://github.com/nityanandagohain)
|
||||
- [Srikanth Chekuri](https://github.com/srikanthccv)
|
||||
- [Vishal Sharma](https://github.com/makeavish)
|
||||
|
||||
#### 前端
|
||||
|
||||
- [Palash Gupta](https://github.com/palashgdev)
|
||||
- [Yunus M](https://github.com/YounixM)
|
||||
- [Rajat Dabade](https://github.com/Rajat-Dabade)
|
||||
|
||||
#### 运维开发
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
|
||||
<br /><br />
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -193,15 +193,6 @@ emailing:
|
||||
templates:
|
||||
# The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go.
|
||||
directory: /opt/signoz/conf/templates/email
|
||||
format:
|
||||
header:
|
||||
enabled: false
|
||||
logo_url: ""
|
||||
help:
|
||||
enabled: false
|
||||
email: ""
|
||||
footer:
|
||||
enabled: false
|
||||
smtp:
|
||||
# The SMTP server address.
|
||||
address: localhost:25
|
||||
@@ -294,6 +285,7 @@ flagger:
|
||||
config:
|
||||
boolean:
|
||||
use_span_metrics: true
|
||||
interpolation_enabled: false
|
||||
kafka_span_eval: false
|
||||
string:
|
||||
float:
|
||||
@@ -309,13 +301,7 @@ user:
|
||||
# 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
|
||||
# The email of the root user.
|
||||
email: root@example.com
|
||||
# The password of the root user.
|
||||
password: Str0ngP@ssw0rd!
|
||||
|
||||
@@ -214,6 +214,7 @@ services:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
|
||||
@@ -155,6 +155,7 @@ services:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
configs:
|
||||
- source: otel-collector-config
|
||||
target: /etc/otel-collector-config.yaml
|
||||
|
||||
@@ -219,6 +219,7 @@ services:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
|
||||
@@ -150,6 +150,7 @@ services:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
|
||||
@@ -4678,8 +4678,6 @@ components:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
isRoot:
|
||||
type: boolean
|
||||
orgId:
|
||||
type: string
|
||||
role:
|
||||
|
||||
@@ -45,7 +45,7 @@ type APIHandler struct {
|
||||
}
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
RuleManager: opts.RulesManager,
|
||||
@@ -58,7 +58,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
}, config)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -175,7 +175,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
GlobalConfig: config.Global,
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface MockUPlotInstance {
|
||||
export interface MockUPlotPaths {
|
||||
spline: jest.Mock;
|
||||
bars: jest.Mock;
|
||||
linear: jest.Mock;
|
||||
stepped: jest.Mock;
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
@@ -25,23 +23,10 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
jest.fn(() => ({
|
||||
name, // To test if the correct pathBuilder is used
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
}));
|
||||
|
||||
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
|
||||
const mockPaths = {
|
||||
spline: jest.fn(() => createMockPathBuilder('spline')),
|
||||
bars: jest.fn(() => createMockPathBuilder('bars')),
|
||||
linear: jest.fn(() => createMockPathBuilder('linear')),
|
||||
stepped: jest.fn((opts?: { align?: number }) =>
|
||||
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
|
||||
),
|
||||
// Create mock paths
|
||||
const mockPaths: MockUPlotPaths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock static methods
|
||||
|
||||
@@ -17,8 +17,6 @@ const config: Config.InitialOptions = {
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^@signozhq/icons$':
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
},
|
||||
globals: {
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@@ -52,10 +52,8 @@
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
"@signozhq/radio-group": "0.0.2",
|
||||
"@signozhq/resizable": "0.0.0",
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/table": "0.3.7",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"SIGN_UP": "SigNoz | Sign Up",
|
||||
"LOGIN": "SigNoz | Login",
|
||||
"FORGOT_PASSWORD": "SigNoz | Forgot Password",
|
||||
"HOME": "SigNoz | Home",
|
||||
"SERVICE_METRICS": "SigNoz | Service Metrics",
|
||||
"SERVICE_MAP": "SigNoz | Service Map",
|
||||
|
||||
@@ -194,10 +194,6 @@ export const Login = Loadable(
|
||||
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
|
||||
);
|
||||
|
||||
export const ForgotPassword = Loadable(
|
||||
() => import(/* webpackChunkName: "ForgotPassword" */ 'pages/ForgotPassword'),
|
||||
);
|
||||
|
||||
export const UnAuthorized = Loadable(
|
||||
() => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'),
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
ErrorDetails,
|
||||
ForgotPassword,
|
||||
Home,
|
||||
InfrastructureMonitoring,
|
||||
InstalledIntegrations,
|
||||
@@ -340,13 +339,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: false,
|
||||
key: 'LOGIN',
|
||||
},
|
||||
{
|
||||
path: ROUTES.FORGOT_PASSWORD,
|
||||
exact: true,
|
||||
component: ForgotPassword,
|
||||
isPrivate: false,
|
||||
key: 'FORGOT_PASSWORD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.UN_AUTHORIZED,
|
||||
exact: true,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
|
||||
const dashboardVariablesQuery = async (
|
||||
props: Props,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
|
||||
try {
|
||||
const { globalTime } = store.getState();
|
||||
@@ -33,7 +32,7 @@ const dashboardVariablesQuery = async (
|
||||
|
||||
payload.variables = { ...payload.variables, ...timeVariables };
|
||||
|
||||
const response = await axios.post(`/variables/query`, payload, { signal });
|
||||
const response = await axios.post(`/variables/query`, payload);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@@ -19,7 +19,6 @@ export const getFieldValues = async (
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
existingQuery?: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<SuccessResponseV2<FieldValueResponse>> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
@@ -48,10 +47,7 @@ export const getFieldValues = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/fields/values', {
|
||||
params,
|
||||
signal: abortSignal,
|
||||
});
|
||||
const response = await axios.get('/fields/values', { params });
|
||||
|
||||
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||
if (response.data?.data?.values) {
|
||||
|
||||
@@ -1542,10 +1542,6 @@ export interface TypesUserDTO {
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isRoot?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
const v5Data: QueryRangeResponseV5 = {
|
||||
type: 'time_series',
|
||||
data: { results: [timeSeries] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
|
||||
};
|
||||
|
||||
const params = makeBaseParams('time_series', [
|
||||
@@ -156,7 +156,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
const v5Data: QueryRangeResponseV5 = {
|
||||
type: 'scalar',
|
||||
data: { results: [scalar] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
|
||||
};
|
||||
|
||||
const params = makeBaseParams('scalar', [
|
||||
@@ -239,7 +239,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
const v5Data: QueryRangeResponseV5 = {
|
||||
type: 'scalar',
|
||||
data: { results: [scalar] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
|
||||
};
|
||||
|
||||
const params = makeBaseParams('scalar', [
|
||||
|
||||
@@ -388,7 +388,6 @@ export function convertV5ResponseToLegacy(
|
||||
warnings: v5Data?.data?.warning || [],
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
meta: v5Data?.meta,
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
};
|
||||
@@ -407,7 +406,6 @@ export function convertV5ResponseToLegacy(
|
||||
payload: {
|
||||
data: convertedData,
|
||||
warning: v5Response.payload?.data?.warning || undefined,
|
||||
meta: v5Data?.meta,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
2
frontend/src/auto-import-registry.d.ts
vendored
2
frontend/src/auto-import-registry.d.ts
vendored
@@ -18,10 +18,8 @@ import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/radio-group';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/sonner';
|
||||
import '@signozhq/table';
|
||||
|
||||
@@ -73,7 +73,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
enableRegexOption = false,
|
||||
isDynamicVariable = false,
|
||||
showRetryButton = true,
|
||||
waitingMessage,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -1682,7 +1681,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!waitingMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
@@ -1700,17 +1698,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<div className="navigation-text">Refreshing values...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && waitingMessage && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text" title={waitingMessage}>
|
||||
{waitingMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && !waitingMessage && (
|
||||
{errorMessage && !loading && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
@@ -1732,7 +1720,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!waitingMessage &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Don't see the value? Use search
|
||||
@@ -1775,7 +1762,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
isDarkMode,
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
waitingMessage,
|
||||
]);
|
||||
|
||||
// Custom handler for dropdown visibility changes
|
||||
|
||||
@@ -63,7 +63,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
showIncompleteDataMessage = false,
|
||||
showRetryButton = true,
|
||||
isDynamicVariable = false,
|
||||
waitingMessage,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -569,7 +568,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!waitingMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
@@ -585,16 +583,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="navigation-text">Refreshing values...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && waitingMessage && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text" title={waitingMessage}>
|
||||
{waitingMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
@@ -617,7 +605,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!waitingMessage &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Don't see the value? Use search
|
||||
@@ -654,7 +641,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
showRetryButton,
|
||||
isDarkMode,
|
||||
isDynamicVariable,
|
||||
waitingMessage,
|
||||
]);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
|
||||
@@ -78,10 +78,12 @@ function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
describe('VariableItem Integration Tests', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
let mockOnValueUpdate: jest.Mock;
|
||||
let mockSetVariablesToGetUpdated: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
mockOnValueUpdate = jest.fn();
|
||||
mockSetVariablesToGetUpdated = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -100,6 +102,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -145,6 +150,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -187,6 +195,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -236,6 +247,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -258,6 +272,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -291,6 +308,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -324,6 +344,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -346,6 +369,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -379,6 +405,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -432,6 +461,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -476,6 +508,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -513,6 +548,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
@@ -544,6 +582,9 @@ describe('VariableItem Integration Tests', () => {
|
||||
variableData={variable}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={null}
|
||||
/>
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
waitingMessage?: string;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
@@ -67,5 +66,4 @@ export interface CustomMultiSelectProps
|
||||
enableRegexOption?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
waitingMessage?: string;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ interface QuerySearchProps {
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -97,7 +96,6 @@ function QuerySearch({
|
||||
onRun,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -254,8 +252,7 @@ function QuerySearch({
|
||||
async (searchText?: string): Promise<void> => {
|
||||
if (
|
||||
dataSource === DataSource.METRICS &&
|
||||
!queryData.aggregateAttribute?.key &&
|
||||
!showFilterSuggestionsWithoutMetric
|
||||
!queryData.aggregateAttribute?.key
|
||||
) {
|
||||
setKeySuggestions([]);
|
||||
return;
|
||||
@@ -304,7 +301,6 @@ function QuerySearch({
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1566,7 +1562,6 @@ QuerySearch.defaultProps = {
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -648,13 +648,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
placement: 'top',
|
||||
mouseEnterDelay: 0.2,
|
||||
mouseLeaveDelay: 0,
|
||||
},
|
||||
}}
|
||||
ellipsis={{ tooltip: { placement: 'top' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -282,11 +282,11 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
size="small"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={showIP ?? true}
|
||||
onChange={(checked): void => {
|
||||
onClick={(): void => {
|
||||
logEvent('API Monitoring: Show IP addresses clicked', {
|
||||
showIP: checked,
|
||||
showIP: !(showIP ?? true),
|
||||
});
|
||||
setParams({ showIP: checked });
|
||||
setParams({ showIP });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
ApiMonitoringParams,
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
@@ -22,15 +18,10 @@ import { QuickFiltersConfig } from './constants';
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/ApiMonitoring/queryParams');
|
||||
|
||||
const handleFilterVisibilityChange = jest.fn();
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const putHandler = jest.fn();
|
||||
const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
|
||||
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void
|
||||
>;
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
@@ -93,28 +84,6 @@ TestQuickFilters.defaultProps = {
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
function TestQuickFiltersApiMonitoring({
|
||||
signal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFiltersApiMonitoring.defaultProps = {
|
||||
signal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
@@ -143,10 +112,6 @@ beforeEach(() => {
|
||||
lastUsedQuery: 0,
|
||||
redirectWithQueryBuilderData,
|
||||
});
|
||||
mockUseApiMonitoringParams.mockReturnValue([
|
||||
{ showIP: true } as ApiMonitoringParams,
|
||||
mockSetApiMonitoringParams,
|
||||
]);
|
||||
setupServer();
|
||||
});
|
||||
|
||||
@@ -286,24 +251,6 @@ describe('Quick Filters', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
it('toggles Show IP addresses and updates API Monitoring params', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFiltersApiMonitoring />);
|
||||
|
||||
// Switch should be rendered and initially checked
|
||||
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetApiMonitoringParams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showIP: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Filters with custom filters', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const ROUTES = {
|
||||
SIGN_UP: '/signup',
|
||||
LOGIN: '/login',
|
||||
FORGOT_PASSWORD: '/forgot-password',
|
||||
HOME: '/home',
|
||||
SERVICE_METRICS: '/services/:servicename',
|
||||
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table } from 'antd';
|
||||
import { Spin, Table, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
@@ -14,13 +14,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
|
||||
import { get } from 'lodash-es';
|
||||
import { MoveUpRight } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
@@ -127,67 +125,51 @@ function DomainList(): JSX.Element {
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
<Table
|
||||
className={cx('api-monitoring-domain-list-table')}
|
||||
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
|
||||
columns={columnsConfig}
|
||||
loading={{
|
||||
spinning: isFetching || isLoading,
|
||||
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
|
||||
}}
|
||||
locale={{
|
||||
emptyText:
|
||||
isFetching || isLoading ? null : (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
<Typography.Text className="no-filtered-domains-message">
|
||||
This query had no results. Edit your query and try again!
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
|
||||
<Table
|
||||
className="api-monitoring-domain-list-table"
|
||||
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
|
||||
columns={columnsConfig}
|
||||
loading={{
|
||||
spinning: isFetching || isLoading,
|
||||
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
tableLayout="fixed"
|
||||
onRow={(record, index): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => {
|
||||
if (index !== undefined) {
|
||||
const dataIndex = formattedDataForTable.findIndex(
|
||||
(item) => item.key === record.key,
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
setParams({ selectedDomain: record.domainName });
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
})}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
),
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
tableLayout="fixed"
|
||||
onRow={(record, index): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => {
|
||||
if (index !== undefined) {
|
||||
const dataIndex = formattedDataForTable.findIndex(
|
||||
(item) => item.key === record.key,
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
setParams({ selectedDomain: record.domainName });
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
})}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
/>
|
||||
{selectedDomainIndex !== -1 && (
|
||||
<DomainDetails
|
||||
domainData={formattedDataForTable[selectedDomainIndex]}
|
||||
|
||||
@@ -180,59 +180,10 @@
|
||||
|
||||
.no-filtered-domains-message {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
|
||||
.no-domain-title {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.no-domain-subtitle {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
|
||||
.attribute {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.external-api-doc-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.no-filtered-domains-message-container {
|
||||
.no-filtered-domains-message-content {
|
||||
.no-filtered-domains-message {
|
||||
.no-domain-title {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.no-domain-subtitle {
|
||||
color: var(--text-ink-400);
|
||||
|
||||
.attribute {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.api-monitoring-domain-list-table {
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
|
||||
@@ -9,6 +9,74 @@
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -467,6 +535,15 @@
|
||||
.dashboard-description-container {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
.dashboard-breadcrumbs {
|
||||
.dashboard-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
.left-section {
|
||||
.dashboard-title {
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
@@ -25,6 +27,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import {
|
||||
Check,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
FolderKanban,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
X,
|
||||
@@ -47,7 +51,6 @@ import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { ComponentTypes } from 'utils/permission';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardGraphSlider from '../ComponentsSlider';
|
||||
import DashboardSettings from '../DashboardSettings';
|
||||
import { Base64Icons } from '../DashboardSettings/General/utils';
|
||||
@@ -68,6 +71,7 @@ interface DashboardDescriptionProps {
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { handle } = props;
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -76,6 +80,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
layouts,
|
||||
setLayouts,
|
||||
isDashboardLocked,
|
||||
listSortOrder,
|
||||
setSelectedDashboard,
|
||||
handleToggleDashboardSlider,
|
||||
setSelectedRowWidgetId,
|
||||
@@ -287,6 +292,17 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
function goToListPage(): void {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
@@ -335,7 +351,32 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader />
|
||||
<div className="dashboard-header">
|
||||
<section className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={(): void => goToListPage()}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
|
||||
@@ -9,15 +9,11 @@ import {
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
initializeVariableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { onUpdateVariableNode } from './util';
|
||||
import VariableItem from './VariableItem';
|
||||
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
@@ -26,6 +22,8 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const {
|
||||
setSelectedDashboard,
|
||||
updateLocalStorageDashboardVariables,
|
||||
variablesToGetUpdated,
|
||||
setVariablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
@@ -57,14 +55,11 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
[dependencyData?.order],
|
||||
);
|
||||
|
||||
// Initialize fetch store then start a new fetch cycle.
|
||||
// Runs on dependency order changes, and time range changes.
|
||||
// Trigger refetch when dependency order changes or global time changes
|
||||
useEffect(() => {
|
||||
const allVariableNames = sortedVariablesArray
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
initializeVariableFetchStore(allVariableNames);
|
||||
enqueueFetchOfAllVariables();
|
||||
if (dependencyData?.order && dependencyData.order.length > 0) {
|
||||
setVariablesToGetUpdated(dependencyData?.order || []);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dependencyOrderKey, minTime, maxTime]);
|
||||
|
||||
@@ -126,14 +121,29 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Cascade: enqueue query-type descendants for refetching
|
||||
enqueueDescendantsOfVariable(name);
|
||||
if (dependencyData) {
|
||||
const updatedVariables: string[] = [];
|
||||
onUpdateVariableNode(
|
||||
name,
|
||||
dependencyData.graph,
|
||||
dependencyData.order,
|
||||
(node) => updatedVariables.push(node),
|
||||
);
|
||||
setVariablesToGetUpdated((prev) => [
|
||||
...new Set([...prev, ...updatedVariables.filter((v) => v !== name)]),
|
||||
]);
|
||||
} else {
|
||||
setVariablesToGetUpdated((prev) => prev.filter((v) => v !== name));
|
||||
}
|
||||
},
|
||||
[
|
||||
// This can be removed
|
||||
dashboardVariables,
|
||||
updateLocalStorageDashboardVariables,
|
||||
dependencyData,
|
||||
updateUrlVariable,
|
||||
setSelectedDashboard,
|
||||
setVariablesToGetUpdated,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -148,6 +158,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={variable}
|
||||
onValueUpdate={onValueUpdate}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,25 +2,18 @@ import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
|
||||
|
||||
import SelectVariableInput from './SelectVariableInput';
|
||||
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
|
||||
import {
|
||||
buildExistingDynamicVariableQuery,
|
||||
extractErrorMessage,
|
||||
getOptionsForDynamicVariable,
|
||||
mergeUniqueStrings,
|
||||
settleVariableFetch,
|
||||
} from './util';
|
||||
import { getOptionsForDynamicVariable } from './util';
|
||||
import { VariableItemProps } from './VariableItem';
|
||||
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
|
||||
|
||||
@@ -31,6 +24,7 @@ type DynamicVariableInputProps = Pick<
|
||||
'variableData' | 'onValueUpdate' | 'existingVariables'
|
||||
>;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DynamicVariableInput({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
@@ -61,8 +55,14 @@ function DynamicVariableInput({
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
// Build a memoized list of all currently available option strings (normalized + related)
|
||||
const allAvailableOptionStrings = useMemo(
|
||||
() => mergeUniqueStrings(optionsData, relatedValues),
|
||||
() => [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
],
|
||||
[optionsData, relatedValues],
|
||||
);
|
||||
|
||||
@@ -104,24 +104,67 @@ function DynamicVariableInput({
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableSettled,
|
||||
isVariableFetching,
|
||||
hasVariableFetchedOnce,
|
||||
isVariableWaitingForDependencies,
|
||||
variableDependencyWaitMessage,
|
||||
} = useVariableFetchState(variableData.name || '');
|
||||
// existing query is the query made from the other dynamic variables around this one with there current values
|
||||
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const existingQuery = useMemo(() => {
|
||||
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() =>
|
||||
buildExistingDynamicVariableQuery(
|
||||
existingVariables,
|
||||
variableData.id,
|
||||
!!variableData.dynamicVariablesAttribute,
|
||||
),
|
||||
[existingVariables, variableData.id, variableData.dynamicVariablesAttribute],
|
||||
);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(existingVariables).forEach(([, variable]) => {
|
||||
// Skip the current variable being processed
|
||||
if (variable.id === variableData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only include dynamic variables that have selected values and are not selected as ALL
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.dynamicVariablesAttribute &&
|
||||
variable.selectedValue &&
|
||||
!isEmpty(variable.selectedValue) &&
|
||||
(variable.showALLOption ? !variable.allSelected : true)
|
||||
) {
|
||||
const attribute = variable.dynamicVariablesAttribute;
|
||||
const values = Array.isArray(variable.selectedValue)
|
||||
? variable.selectedValue
|
||||
: [variable.selectedValue];
|
||||
|
||||
// Filter out empty values and convert to strings
|
||||
const validValues = values
|
||||
.filter((val) => val !== null && val !== undefined && val !== '')
|
||||
.map((val) => val.toString());
|
||||
|
||||
if (validValues.length > 0) {
|
||||
// Format values for query - wrap strings in quotes, keep numbers as is
|
||||
const formattedValues = validValues.map((val) => {
|
||||
// Check if value is a number
|
||||
const numValue = Number(val);
|
||||
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
|
||||
return val; // Keep as number
|
||||
}
|
||||
// Escape single quotes and wrap in quotes
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
});
|
||||
|
||||
if (formattedValues.length === 1) {
|
||||
queryParts.push(`${attribute} = ${formattedValues[0]}`);
|
||||
} else {
|
||||
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return queryParts.join(' AND ');
|
||||
}, [
|
||||
existingVariables,
|
||||
variableData.id,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
]);
|
||||
|
||||
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
|
||||
const handleSelectDropdownVisibilityChange = useCallback(
|
||||
@@ -139,73 +182,6 @@ function DynamicVariableInput({
|
||||
[onDropdownVisibleChange, optionsData, originalRelatedValues],
|
||||
);
|
||||
|
||||
const handleQuerySuccess = useCallback(
|
||||
(data: SuccessResponseV2<FieldValueResponse>): void => {
|
||||
const newNormalizedValues = data.data?.normalizedValues || [];
|
||||
const newRelatedValues = data.data?.relatedValues || [];
|
||||
|
||||
if (!debouncedApiSearchText) {
|
||||
setOptionsData(newNormalizedValues);
|
||||
setIsComplete(data.data?.complete || false);
|
||||
}
|
||||
setFilteredOptionsData(newNormalizedValues);
|
||||
setRelatedValues(newRelatedValues);
|
||||
setOriginalRelatedValues(newRelatedValues);
|
||||
|
||||
// Sync temp selection with latest API values when ALL is active and dropdown is open
|
||||
if (variableData.allSelected && isDropdownOpen) {
|
||||
const latestValues = mergeUniqueStrings(
|
||||
newNormalizedValues,
|
||||
newRelatedValues,
|
||||
);
|
||||
|
||||
const currentStrings = Array.isArray(tempSelection)
|
||||
? tempSelection.map((v) => v.toString())
|
||||
: tempSelection
|
||||
? [tempSelection.toString()]
|
||||
: [];
|
||||
|
||||
const areSame =
|
||||
currentStrings.length === latestValues.length &&
|
||||
latestValues.every((v) => currentStrings.includes(v));
|
||||
|
||||
if (!areSame) {
|
||||
setTempSelection(latestValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
if (!debouncedApiSearchText) {
|
||||
applyDefaultIfNeeded(
|
||||
mergeUniqueStrings(newNormalizedValues, newRelatedValues),
|
||||
);
|
||||
}
|
||||
|
||||
settleVariableFetch(variableData.name, 'complete');
|
||||
},
|
||||
[
|
||||
debouncedApiSearchText,
|
||||
variableData.allSelected,
|
||||
variableData.name,
|
||||
isDropdownOpen,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
applyDefaultIfNeeded,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryError = useCallback(
|
||||
(error: { message?: string } | null): void => {
|
||||
if (error) {
|
||||
setErrorMessage(extractErrorMessage(error));
|
||||
setIsRetryableError(checkIfRetryableError(error));
|
||||
}
|
||||
|
||||
settleVariableFetch(variableData.name, 'failure');
|
||||
},
|
||||
[variableData.name],
|
||||
);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
@@ -216,22 +192,13 @@ function DynamicVariableInput({
|
||||
debouncedApiSearchText,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
{
|
||||
/*
|
||||
* enabled if
|
||||
* - we have dynamic variable source and attribute defined (ALWAYS)
|
||||
* - AND
|
||||
* - we're either still fetching variable options
|
||||
* - OR
|
||||
* - if variable is in idle state and we have already fetched options for it
|
||||
**/
|
||||
enabled:
|
||||
variableData.type === 'DYNAMIC' &&
|
||||
!!variableData.dynamicVariablesSource &&
|
||||
!!variableData.dynamicVariablesAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
queryFn: ({ signal }) =>
|
||||
!!variableData.dynamicVariablesAttribute,
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
|
||||
? undefined
|
||||
@@ -244,10 +211,70 @@ function DynamicVariableInput({
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery,
|
||||
signal,
|
||||
),
|
||||
onSuccess: handleQuerySuccess,
|
||||
onError: handleQueryError,
|
||||
onSuccess: (data) => {
|
||||
const newNormalizedValues = data.data?.normalizedValues || [];
|
||||
const newRelatedValues = data.data?.relatedValues || [];
|
||||
|
||||
if (!debouncedApiSearchText) {
|
||||
setOptionsData(newNormalizedValues);
|
||||
setIsComplete(data.data?.complete || false);
|
||||
}
|
||||
setFilteredOptionsData(newNormalizedValues);
|
||||
setRelatedValues(newRelatedValues);
|
||||
setOriginalRelatedValues(newRelatedValues);
|
||||
|
||||
// Only run auto-check logic when necessary to avoid performance issues
|
||||
if (variableData.allSelected && isDropdownOpen) {
|
||||
// Build the latest full list from API (normalized + related)
|
||||
const latestValues = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
// Update temp selection to exactly reflect latest API values when ALL is active
|
||||
const currentStrings = Array.isArray(tempSelection)
|
||||
? tempSelection.map((v) => v.toString())
|
||||
: tempSelection
|
||||
? [tempSelection.toString()]
|
||||
: [];
|
||||
const areSame =
|
||||
currentStrings.length === latestValues.length &&
|
||||
latestValues.every((v) => currentStrings.includes(v));
|
||||
if (!areSame) {
|
||||
setTempSelection(latestValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
if (!debouncedApiSearchText) {
|
||||
const allNewOptions = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
applyDefaultIfNeeded(allNewOptions);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error) {
|
||||
let message = SOMETHING_WENT_WRONG;
|
||||
if (error?.message) {
|
||||
message = error?.message;
|
||||
} else {
|
||||
message =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
}
|
||||
setErrorMessage(message);
|
||||
|
||||
// Check if error is retryable (5xx) or not (4xx)
|
||||
const isRetryable = checkIfRetryableError(error);
|
||||
setIsRetryableError(isRetryable);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -309,8 +336,6 @@ function DynamicVariableInput({
|
||||
showRetryButton={isRetryableError}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
onSearch={handleSearch}
|
||||
waiting={isVariableWaitingForDependencies}
|
||||
waitingMessage={variableDependencyWaitMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import { isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { isArray, isString } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -13,18 +12,26 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { variablePropsToPayloadVariables } from '../utils';
|
||||
import SelectVariableInput from './SelectVariableInput';
|
||||
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
|
||||
import { areArraysEqual, settleVariableFetch } from './util';
|
||||
import { areArraysEqual, checkAPIInvocation } from './util';
|
||||
import { VariableItemProps } from './VariableItem';
|
||||
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
|
||||
|
||||
type QueryVariableInputProps = Pick<
|
||||
VariableItemProps,
|
||||
'variableData' | 'existingVariables' | 'onValueUpdate'
|
||||
| 'variableData'
|
||||
| 'existingVariables'
|
||||
| 'onValueUpdate'
|
||||
| 'variablesToGetUpdated'
|
||||
| 'setVariablesToGetUpdated'
|
||||
| 'dependencyData'
|
||||
>;
|
||||
|
||||
function QueryVariableInput({
|
||||
variableData,
|
||||
existingVariables,
|
||||
variablesToGetUpdated,
|
||||
setVariablesToGetUpdated,
|
||||
dependencyData,
|
||||
onValueUpdate,
|
||||
}: QueryVariableInputProps): JSX.Element {
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
@@ -36,15 +43,6 @@ function QueryVariableInput({
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableSettled,
|
||||
isVariableFetching,
|
||||
hasVariableFetchedOnce,
|
||||
isVariableWaitingForDependencies,
|
||||
variableDependencyWaitMessage,
|
||||
} = useVariableFetchState(variableData.name || '');
|
||||
|
||||
const {
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
@@ -62,6 +60,16 @@ function QueryVariableInput({
|
||||
strategy: queryVariableSelectStrategy,
|
||||
});
|
||||
|
||||
const validVariableUpdate = useCallback((): boolean => {
|
||||
if (!variableData.name) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
variablesToGetUpdated.length &&
|
||||
variablesToGetUpdated[0] === variableData.name,
|
||||
);
|
||||
}, [variableData.name, variablesToGetUpdated]);
|
||||
|
||||
const getOptions = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(variablesRes: VariableResponseProps | null): void => {
|
||||
@@ -95,24 +103,18 @@ function QueryVariableInput({
|
||||
valueNotInList = true;
|
||||
}
|
||||
|
||||
if (variableData.name && (valueNotInList || variableData.allSelected)) {
|
||||
// variablesData.allSelected is added for the case where on change of options we need to update the
|
||||
// local storage
|
||||
if (
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
) {
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
newOptionsData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
|
||||
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
@@ -130,11 +132,7 @@ function QueryVariableInput({
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
if (variableData.name && variableData.id) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +141,10 @@ function QueryVariableInput({
|
||||
setOptionsData(newOptionsData);
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
applyDefaultIfNeeded(newOptionsData);
|
||||
} else {
|
||||
setVariablesToGetUpdated((prev) =>
|
||||
prev.filter((name) => name !== variableData.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -155,6 +157,8 @@ function QueryVariableInput({
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
validVariableUpdate,
|
||||
setVariablesToGetUpdated,
|
||||
applyDefaultIfNeeded,
|
||||
],
|
||||
);
|
||||
@@ -165,28 +169,27 @@ function QueryVariableInput({
|
||||
variableData.name || '',
|
||||
`${minTime}`,
|
||||
`${maxTime}`,
|
||||
variableFetchCycleId,
|
||||
JSON.stringify(dependencyData?.order),
|
||||
],
|
||||
{
|
||||
/*
|
||||
* enabled if
|
||||
* - we're either still fetching variable options
|
||||
* - OR
|
||||
* - if variable is in idle state and we have already fetched options for it
|
||||
**/
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
queryFn: ({ signal }) =>
|
||||
dashboardVariablesQuery(
|
||||
{
|
||||
query: variableData.queryValue || '',
|
||||
variables: variablePropsToPayloadVariables(existingVariables),
|
||||
},
|
||||
signal,
|
||||
enabled:
|
||||
variableData &&
|
||||
checkAPIInvocation(
|
||||
variablesToGetUpdated,
|
||||
variableData,
|
||||
dependencyData?.parentDependencyGraph,
|
||||
),
|
||||
queryFn: () =>
|
||||
dashboardVariablesQuery({
|
||||
query: variableData.queryValue || '',
|
||||
variables: variablePropsToPayloadVariables(existingVariables),
|
||||
}),
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: (response) => {
|
||||
getOptions(response.payload);
|
||||
settleVariableFetch(variableData.name, 'complete');
|
||||
setVariablesToGetUpdated((prev) =>
|
||||
prev.filter((v) => v !== variableData.name),
|
||||
);
|
||||
},
|
||||
onError: (error: {
|
||||
details: {
|
||||
@@ -203,7 +206,9 @@ function QueryVariableInput({
|
||||
}
|
||||
setErrorMessage(message);
|
||||
}
|
||||
settleVariableFetch(variableData.name, 'failure');
|
||||
setVariablesToGetUpdated((prev) =>
|
||||
prev.filter((v) => v !== variableData.name),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -237,8 +242,6 @@ function QueryVariableInput({
|
||||
loading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={handleRetry}
|
||||
waiting={isVariableWaitingForDependencies}
|
||||
waitingMessage={variableDependencyWaitMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ interface SelectVariableInputProps {
|
||||
showRetryButton?: boolean;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
onSearch?: (searchTerm: string) => void;
|
||||
waiting?: boolean;
|
||||
waitingMessage?: string;
|
||||
}
|
||||
|
||||
const MAX_TAG_DISPLAY_VALUES = 10;
|
||||
@@ -67,7 +65,6 @@ function SelectVariableInput({
|
||||
showRetryButton,
|
||||
showIncompleteDataMessage,
|
||||
onSearch,
|
||||
waitingMessage,
|
||||
}: SelectVariableInputProps): JSX.Element {
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
@@ -81,6 +78,7 @@ function SelectVariableInput({
|
||||
className: 'variable-select',
|
||||
popupClassName: 'dropdown-styles',
|
||||
getPopupContainer: popupContainer,
|
||||
style: SelectItemStyle,
|
||||
showSearch: true,
|
||||
bordered: false,
|
||||
|
||||
@@ -88,8 +86,6 @@ function SelectVariableInput({
|
||||
'data-testid': 'variable-select',
|
||||
onChange,
|
||||
loading,
|
||||
waitingMessage,
|
||||
style: SelectItemStyle,
|
||||
options,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
@@ -105,7 +101,6 @@ function SelectVariableInput({
|
||||
defaultValue,
|
||||
onChange,
|
||||
loading,
|
||||
waitingMessage,
|
||||
options,
|
||||
value,
|
||||
errorMessage,
|
||||
|
||||
@@ -47,6 +47,14 @@ describe('VariableItem', () => {
|
||||
variableData={mockVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
@@ -61,6 +69,14 @@ describe('VariableItem', () => {
|
||||
variableData={mockVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
@@ -76,6 +92,14 @@ describe('VariableItem', () => {
|
||||
variableData={mockVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
@@ -109,6 +133,14 @@ describe('VariableItem', () => {
|
||||
variableData={mockCustomVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
@@ -131,6 +163,14 @@ describe('VariableItem', () => {
|
||||
variableData={customVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
@@ -145,6 +185,14 @@ describe('VariableItem', () => {
|
||||
variableData={mockCustomVariableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={(): void => {}}
|
||||
dependencyData={{
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
}}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import CustomVariableInput from './CustomVariableInput';
|
||||
@@ -20,12 +21,18 @@ export interface VariableItemProps {
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
dependencyData: IDependencyData | null;
|
||||
}
|
||||
|
||||
function VariableItem({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
existingVariables,
|
||||
variablesToGetUpdated,
|
||||
setVariablesToGetUpdated,
|
||||
dependencyData,
|
||||
}: VariableItemProps): JSX.Element {
|
||||
const { name, description, type: variableType } = variableData;
|
||||
|
||||
@@ -58,6 +65,9 @@ function VariableItem({
|
||||
variableData={variableData}
|
||||
onValueUpdate={onValueUpdate}
|
||||
existingVariables={existingVariables}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
)}
|
||||
{variableType === 'DYNAMIC' && (
|
||||
|
||||
@@ -7,19 +7,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableInput from '../DynamicVariableInput';
|
||||
|
||||
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
|
||||
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
|
||||
useVariableFetchState: (): Record<string, unknown> => ({
|
||||
variableFetchCycleId: 0,
|
||||
variableFetchState: 'loading',
|
||||
isVariableSettled: false,
|
||||
isVariableFetching: true,
|
||||
hasVariableFetchedOnce: false,
|
||||
isVariableWaitingForDependencies: false,
|
||||
variableDependencyWaitMessage: '',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Don't mock the components - use real ones
|
||||
|
||||
// Mock for useQuery
|
||||
@@ -230,10 +217,9 @@ describe('DynamicVariableInput Component', () => {
|
||||
'',
|
||||
'Traces',
|
||||
'service.name',
|
||||
0, // variableFetchCycleId
|
||||
],
|
||||
expect.objectContaining({
|
||||
enabled: true, // isVariableFetching is true from mock
|
||||
enabled: true, // Type is 'DYNAMIC'
|
||||
queryFn: expect.any(Function),
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
|
||||
@@ -8,6 +8,14 @@ import '@testing-library/jest-dom/extend-expect';
|
||||
import VariableItem from '../VariableItem';
|
||||
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
const mockSetVariablesToGetUpdated = jest.fn();
|
||||
|
||||
const baseDependencyData = {
|
||||
order: [],
|
||||
graph: {},
|
||||
parentDependencyGraph: {},
|
||||
hasCycle: false,
|
||||
};
|
||||
|
||||
const TEST_VARIABLE_ID = 'test_variable';
|
||||
const VARIABLE_SELECT_TESTID = 'variable-select';
|
||||
@@ -23,6 +31,9 @@ const renderVariableItem = (
|
||||
variableData={variableData}
|
||||
existingVariables={{}}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
variablesToGetUpdated={[]}
|
||||
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
|
||||
dependencyData={baseDependencyData}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -2,12 +2,14 @@ import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
buildParentDependencyGraph,
|
||||
checkAPIInvocation,
|
||||
onUpdateVariableNode,
|
||||
VariableGraph,
|
||||
} from '../util';
|
||||
import {
|
||||
buildDependenciesMock,
|
||||
buildGraphMock,
|
||||
checkAPIInvocationMock,
|
||||
onUpdateVariableNodeMock,
|
||||
} from './mock';
|
||||
|
||||
@@ -70,6 +72,97 @@ describe('dashboardVariables - utilities and processors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAPIInvocation', () => {
|
||||
const {
|
||||
variablesToGetUpdated,
|
||||
variableData,
|
||||
parentDependencyGraph,
|
||||
} = checkAPIInvocationMock;
|
||||
|
||||
const mockRootElement = {
|
||||
name: 'deployment_environment',
|
||||
key: '036a47cd-9ffc-47de-9f27-0329198964a8',
|
||||
id: '036a47cd-9ffc-47de-9f27-0329198964a8',
|
||||
modificationUUID: '5f71b591-f583-497c-839d-6a1590c3f60f',
|
||||
selectedValue: 'production',
|
||||
type: 'QUERY',
|
||||
// ... other properties omitted for brevity
|
||||
} as any;
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return false when variableData is empty', () => {
|
||||
expect(
|
||||
checkAPIInvocation(
|
||||
variablesToGetUpdated,
|
||||
variableData,
|
||||
parentDependencyGraph,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return true when parentDependencyGraph is empty', () => {
|
||||
expect(
|
||||
checkAPIInvocation(variablesToGetUpdated, variableData, {}),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable sequences', () => {
|
||||
it('should return true for valid sequence', () => {
|
||||
expect(
|
||||
checkAPIInvocation(
|
||||
['k8s_node_name', 'k8s_namespace_name'],
|
||||
variableData,
|
||||
parentDependencyGraph,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false for invalid sequence', () => {
|
||||
expect(
|
||||
checkAPIInvocation(
|
||||
['k8s_cluster_name', 'k8s_node_name', 'k8s_namespace_name'],
|
||||
variableData,
|
||||
parentDependencyGraph,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return false when variableData is not in sequence', () => {
|
||||
expect(
|
||||
checkAPIInvocation(
|
||||
['deployment_environment', 'service_name', 'endpoint'],
|
||||
variableData,
|
||||
parentDependencyGraph,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('root element behavior', () => {
|
||||
it('should return true for valid root element sequence', () => {
|
||||
expect(
|
||||
checkAPIInvocation(
|
||||
[
|
||||
'deployment_environment',
|
||||
'service_name',
|
||||
'endpoint',
|
||||
'http_status_code',
|
||||
],
|
||||
mockRootElement,
|
||||
parentDependencyGraph,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return true for empty variablesToGetUpdated array', () => {
|
||||
expect(
|
||||
checkAPIInvocation([], mockRootElement, parentDependencyGraph),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Graph Building Utilities', () => {
|
||||
const { graph } = buildGraphMock;
|
||||
const { variables } = buildDependenciesMock;
|
||||
@@ -130,86 +223,10 @@ describe('dashboardVariables - utilities and processors', () => {
|
||||
},
|
||||
hasCycle: false,
|
||||
cycleNodes: undefined,
|
||||
transitiveDescendants: {
|
||||
deployment_environment: ['service_name', 'endpoint', 'http_status_code'],
|
||||
endpoint: ['http_status_code'],
|
||||
environment: [],
|
||||
http_status_code: [],
|
||||
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
|
||||
k8s_namespace_name: [],
|
||||
k8s_node_name: ['k8s_namespace_name'],
|
||||
service_name: ['endpoint', 'http_status_code'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDependencyGraph(graph)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return empty transitiveDescendants for an empty graph', () => {
|
||||
const result = buildDependencyGraph({});
|
||||
expect(result.transitiveDescendants).toEqual({});
|
||||
expect(result.order).toEqual([]);
|
||||
expect(result.hasCycle).toBe(false);
|
||||
});
|
||||
|
||||
it('should compute transitiveDescendants for a linear chain (a -> b -> c)', () => {
|
||||
const linearGraph: VariableGraph = {
|
||||
a: ['b'],
|
||||
b: ['c'],
|
||||
c: [],
|
||||
};
|
||||
const result = buildDependencyGraph(linearGraph);
|
||||
expect(result.transitiveDescendants).toEqual({
|
||||
a: ['b', 'c'],
|
||||
b: ['c'],
|
||||
c: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute transitiveDescendants for a diamond dependency (a -> b, a -> c, b -> d, c -> d)', () => {
|
||||
const diamondGraph: VariableGraph = {
|
||||
a: ['b', 'c'],
|
||||
b: ['d'],
|
||||
c: ['d'],
|
||||
d: [],
|
||||
};
|
||||
const result = buildDependencyGraph(diamondGraph);
|
||||
expect(result.transitiveDescendants.a).toEqual(
|
||||
expect.arrayContaining(['b', 'c', 'd']),
|
||||
);
|
||||
expect(result.transitiveDescendants.a).toHaveLength(3);
|
||||
expect(result.transitiveDescendants.b).toEqual(['d']);
|
||||
expect(result.transitiveDescendants.c).toEqual(['d']);
|
||||
expect(result.transitiveDescendants.d).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle disconnected components in transitiveDescendants', () => {
|
||||
const disconnectedGraph: VariableGraph = {
|
||||
a: ['b'],
|
||||
b: [],
|
||||
x: ['y'],
|
||||
y: [],
|
||||
};
|
||||
const result = buildDependencyGraph(disconnectedGraph);
|
||||
expect(result.transitiveDescendants.a).toEqual(['b']);
|
||||
expect(result.transitiveDescendants.b).toEqual([]);
|
||||
expect(result.transitiveDescendants.x).toEqual(['y']);
|
||||
expect(result.transitiveDescendants.y).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty transitiveDescendants for all leaf nodes', () => {
|
||||
const leafOnlyGraph: VariableGraph = {
|
||||
a: [],
|
||||
b: [],
|
||||
c: [],
|
||||
};
|
||||
const result = buildDependencyGraph(leafOnlyGraph);
|
||||
expect(result.transitiveDescendants).toEqual({
|
||||
a: [],
|
||||
b: [],
|
||||
c: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDependencies', () => {
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
export const checkAPIInvocationMock = {
|
||||
variablesToGetUpdated: [],
|
||||
variableData: {
|
||||
name: 'k8s_node_name',
|
||||
key: '4d71d385-beaf-4434-8dbf-c62be68049fc',
|
||||
allSelected: false,
|
||||
customValue: '',
|
||||
description: '',
|
||||
id: '4d71d385-beaf-4434-8dbf-c62be68049fc',
|
||||
modificationUUID: '77233d3c-96d7-4ccb-aa9d-11b04d563068',
|
||||
multiSelect: false,
|
||||
order: 6,
|
||||
queryValue:
|
||||
"SELECT JSONExtractString(labels, 'k8s_node_name') AS k8s_node_name\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'k8s_node_cpu_time' AND JSONExtractString(labels, 'k8s_cluster_name') = {{.k8s_cluster_name}}\nGROUP BY k8s_node_name",
|
||||
selectedValue: 'gke-signoz-saas-si-consumer-bsc-e2sd4-a6d430fa-gvm2',
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
textboxValue: '',
|
||||
type: 'QUERY',
|
||||
},
|
||||
parentDependencyGraph: {
|
||||
deployment_environment: [],
|
||||
service_name: ['deployment_environment'],
|
||||
endpoint: ['deployment_environment', 'service_name'],
|
||||
http_status_code: ['endpoint'],
|
||||
k8s_cluster_name: [],
|
||||
environment: [],
|
||||
k8s_node_name: ['k8s_cluster_name'],
|
||||
k8s_namespace_name: ['k8s_cluster_name', 'k8s_node_name'],
|
||||
},
|
||||
} as any;
|
||||
|
||||
export const onUpdateVariableNodeMock = {
|
||||
nodeToUpdate: 'deployment_environment',
|
||||
graph: {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import {
|
||||
IDashboardVariables,
|
||||
IDependencyData,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
onVariableFetchComplete,
|
||||
onVariableFetchFailure,
|
||||
variableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
export function areArraysEqual(
|
||||
@@ -52,16 +45,30 @@ const getDependentVariablesBasedOnVariableName = (
|
||||
}
|
||||
|
||||
return variables
|
||||
.map((variable) => {
|
||||
?.map((variable: any) => {
|
||||
if (variable.type === 'QUERY') {
|
||||
// Combined pattern for all formats
|
||||
// {{.variable_name}} - original format
|
||||
// $variable_name - dollar prefix format
|
||||
// [[variable_name]] - square bracket format
|
||||
// {{variable_name}} - without dot format
|
||||
const patterns = [
|
||||
`\\{\\{\\s*?\\.${variableName}\\s*?\\}\\}`, // {{.var}}
|
||||
`\\{\\{\\s*${variableName}\\s*\\}\\}`, // {{var}}
|
||||
`\\$${variableName}\\b`, // $var
|
||||
`\\[\\[\\s*${variableName}\\s*\\]\\]`, // [[var]]
|
||||
];
|
||||
const combinedRegex = new RegExp(patterns.join('|'));
|
||||
|
||||
const queryValue = variable.queryValue || '';
|
||||
if (textContainsVariableReference(queryValue, variableName)) {
|
||||
const dependVarReMatch = queryValue.match(combinedRegex);
|
||||
if (dependVarReMatch !== null && dependVarReMatch.length > 0) {
|
||||
return variable.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((val): val is string => val !== null);
|
||||
.filter((val: string | null) => !isNull(val));
|
||||
};
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
@@ -239,26 +246,10 @@ export const buildDependencyGraph = (
|
||||
|
||||
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
|
||||
|
||||
// Pre-compute transitive descendants by walking topological order in reverse.
|
||||
// Each node's transitive descendants = direct children + their transitive descendants.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
|
||||
const node = topologicalOrder[i];
|
||||
const desc = new Set<string>();
|
||||
for (const child of adjList[node] || []) {
|
||||
desc.add(child);
|
||||
for (const d of transitiveDescendants[child] || []) {
|
||||
desc.add(d);
|
||||
}
|
||||
}
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order: topologicalOrder,
|
||||
graph: adjList,
|
||||
parentDependencyGraph: buildParentDependencyGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
@@ -293,6 +284,33 @@ export const onUpdateVariableNode = (
|
||||
});
|
||||
};
|
||||
|
||||
export const checkAPIInvocation = (
|
||||
variablesToGetUpdated: string[],
|
||||
variableData: IDashboardVariable,
|
||||
parentDependencyGraph?: VariableGraph,
|
||||
): boolean => {
|
||||
if (isEmpty(variableData.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEmpty(parentDependencyGraph)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if no dependency then true
|
||||
const haveDependency =
|
||||
parentDependencyGraph?.[variableData.name || '']?.length > 0;
|
||||
if (!haveDependency) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if variable is in the list and has dependency then check if its the top element in the queue then true else false
|
||||
return (
|
||||
variablesToGetUpdated.length > 0 &&
|
||||
variablesToGetUpdated[0] === variableData.name
|
||||
);
|
||||
};
|
||||
|
||||
export const getOptionsForDynamicVariable = (
|
||||
normalizedValues: (string | number | boolean)[],
|
||||
relatedValues: string[],
|
||||
@@ -357,130 +375,3 @@ export const getSelectValue = (
|
||||
}
|
||||
return selectedValue?.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Merges multiple arrays of values into a single deduplicated string array.
|
||||
*/
|
||||
export function mergeUniqueStrings(
|
||||
...arrays: (string | number | boolean)[][]
|
||||
): string[] {
|
||||
return [...new Set(arrays.flatMap((arr) => arr.map((v) => v.toString())))];
|
||||
}
|
||||
|
||||
function isEligibleFilterVariable(
|
||||
variable: IDashboardVariable,
|
||||
currentVariableId: string,
|
||||
): boolean {
|
||||
if (variable.id === currentVariableId) {
|
||||
return false;
|
||||
}
|
||||
if (variable.type !== 'DYNAMIC') {
|
||||
return false;
|
||||
}
|
||||
if (!variable.dynamicVariablesAttribute) {
|
||||
return false;
|
||||
}
|
||||
if (!variable.selectedValue || isEmpty(variable.selectedValue)) {
|
||||
return false;
|
||||
}
|
||||
return !(variable.showALLOption && variable.allSelected);
|
||||
}
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
const numValue = Number(val);
|
||||
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
|
||||
return val;
|
||||
}
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function buildQueryPart(attribute: string, values: string[]): string {
|
||||
const formatted = values.map(formatQueryValue);
|
||||
if (formatted.length === 1) {
|
||||
return `${attribute} = ${formatted[0]}`;
|
||||
}
|
||||
return `${attribute} IN [${formatted.join(', ')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter query string from sibling dynamic variables' selected values.
|
||||
* e.g. `k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'`
|
||||
*/
|
||||
export function buildExistingDynamicVariableQuery(
|
||||
existingVariables: IDashboardVariables | null,
|
||||
currentVariableId: string,
|
||||
hasDynamicAttribute: boolean,
|
||||
): string {
|
||||
if (!existingVariables || !hasDynamicAttribute) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (const variable of Object.values(existingVariables)) {
|
||||
// Skip the current variable being processed
|
||||
if (!isEligibleFilterVariable(variable, currentVariableId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValues = Array.isArray(variable.selectedValue)
|
||||
? variable.selectedValue
|
||||
: [variable.selectedValue];
|
||||
|
||||
// Filter out empty values and convert to strings
|
||||
const validValues = rawValues
|
||||
.filter(
|
||||
(val): val is string | number | boolean =>
|
||||
val !== null && val !== undefined && val !== '',
|
||||
)
|
||||
.map((val) => val.toString());
|
||||
|
||||
if (validValues.length > 0 && variable.dynamicVariablesAttribute) {
|
||||
queryParts.push(
|
||||
buildQueryPart(variable.dynamicVariablesAttribute, validValues),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return queryParts.join(' AND ');
|
||||
}
|
||||
|
||||
function isVariableInActiveFetchState(state: string | undefined): boolean {
|
||||
return state === 'loading' || state === 'revalidating';
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes or fails a variable's fetch state machine transition.
|
||||
* No-ops if the variable is not currently in an active fetch state.
|
||||
*/
|
||||
export function settleVariableFetch(
|
||||
name: string | undefined,
|
||||
outcome: 'complete' | 'failure',
|
||||
): void {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = variableFetchStore.getSnapshot().states[name];
|
||||
if (!isVariableInActiveFetchState(currentState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome === 'complete') {
|
||||
onVariableFetchComplete(name);
|
||||
} else {
|
||||
onVariableFetchFailure(name);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractErrorMessage(
|
||||
error: { message?: string } | null,
|
||||
): string {
|
||||
if (!error) {
|
||||
return SOMETHING_WENT_WRONG;
|
||||
}
|
||||
return (
|
||||
error.message ||
|
||||
'Please make sure configuration is valid and you have required setup and permissions'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,4 @@
|
||||
jest.mock('providers/Dashboard/store/variableFetchStore', () => ({
|
||||
variableFetchStore: {
|
||||
getSnapshot: jest.fn(),
|
||||
},
|
||||
onVariableFetchComplete: jest.fn(),
|
||||
onVariableFetchFailure: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
onVariableFetchComplete,
|
||||
onVariableFetchFailure,
|
||||
variableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import {
|
||||
areArraysEqual,
|
||||
buildExistingDynamicVariableQuery,
|
||||
extractErrorMessage,
|
||||
mergeUniqueStrings,
|
||||
onUpdateVariableNode,
|
||||
settleVariableFetch,
|
||||
VariableGraph,
|
||||
} from './util';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Existing tests
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
import { areArraysEqual, onUpdateVariableNode, VariableGraph } from './util';
|
||||
|
||||
describe('areArraysEqual', () => {
|
||||
it('should return true for equal arrays with same order', () => {
|
||||
@@ -176,348 +149,3 @@ describe('onUpdateVariableNode', () => {
|
||||
expect(visited).toEqual(['namespace', 'service', 'pod']);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// New tests for functions added in recent commits
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDynamicVar(
|
||||
overrides: Partial<IDashboardVariable> & { id: string },
|
||||
): IDashboardVariable {
|
||||
return {
|
||||
name: overrides.id,
|
||||
description: '',
|
||||
type: 'DYNAMIC',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
dynamicVariablesAttribute: 'attr',
|
||||
selectedValue: 'some-value',
|
||||
...overrides,
|
||||
} as IDashboardVariable;
|
||||
}
|
||||
|
||||
describe('mergeUniqueStrings', () => {
|
||||
it('should merge two arrays and deduplicate', () => {
|
||||
expect(mergeUniqueStrings(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should convert numbers and booleans to strings', () => {
|
||||
expect(mergeUniqueStrings([1, true, 'hello'], [2, false])).toEqual([
|
||||
'1',
|
||||
'true',
|
||||
'hello',
|
||||
'2',
|
||||
'false',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate when number and its string form both appear', () => {
|
||||
expect(mergeUniqueStrings([42], ['42'])).toEqual(['42']);
|
||||
});
|
||||
|
||||
it('should handle a single array', () => {
|
||||
expect(mergeUniqueStrings(['x', 'y', 'x'])).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('should handle three or more arrays', () => {
|
||||
expect(mergeUniqueStrings(['a'], ['b'], ['c'], ['a', 'c'])).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no arrays are provided', () => {
|
||||
expect(mergeUniqueStrings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when all input arrays are empty', () => {
|
||||
expect(mergeUniqueStrings([], [], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve order of first occurrence', () => {
|
||||
expect(mergeUniqueStrings(['c', 'a'], ['b', 'a'])).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExistingDynamicVariableQuery', () => {
|
||||
// --- Guard clauses ---
|
||||
it('should return empty string when existingVariables is null', () => {
|
||||
expect(buildExistingDynamicVariableQuery(null, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when hasDynamicAttribute is false', () => {
|
||||
const variables = { v2: makeDynamicVar({ id: 'v2' }) };
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', false)).toBe('');
|
||||
});
|
||||
|
||||
// --- Eligibility filtering ---
|
||||
it('should skip the current variable (same id)', () => {
|
||||
const variables = {
|
||||
v1: makeDynamicVar({
|
||||
id: 'v1',
|
||||
dynamicVariablesAttribute: 'ns',
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip non-DYNAMIC variables', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip variables without dynamicVariablesAttribute', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: undefined,
|
||||
selectedValue: 'val',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip variables with null selectedValue', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({ id: 'v2', selectedValue: null }),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip variables with empty string selectedValue', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({ id: 'v2', selectedValue: '' }),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip variables with empty array selectedValue', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({ id: 'v2', selectedValue: [] }),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should skip variables where showALLOption and allSelected are both true', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
showALLOption: true,
|
||||
allSelected: true,
|
||||
dynamicVariablesAttribute: 'ns',
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
|
||||
it('should include variable with showALLOption true but allSelected false', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
showALLOption: true,
|
||||
allSelected: false,
|
||||
dynamicVariablesAttribute: 'ns',
|
||||
selectedValue: 'prod',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"ns = 'prod'",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Value formatting ---
|
||||
it('should quote string values in the query', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'k8s.namespace.name',
|
||||
selectedValue: 'zeus',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"k8s.namespace.name = 'zeus'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should leave numeric values unquoted', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'http.status_code',
|
||||
selectedValue: '200',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
'http.status_code = 200',
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape single quotes in string values', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'user.name',
|
||||
selectedValue: "O'Brien",
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"user.name = 'O\\'Brien'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should build an IN clause for array selectedValue with multiple items', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'k8s.namespace.name',
|
||||
selectedValue: ['zeus', 'gene'],
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"k8s.namespace.name IN ['zeus', 'gene']",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mix of numeric and string values in IN clause', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'http.status_code',
|
||||
selectedValue: ['200', 'unknown'],
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"http.status_code IN [200, 'unknown']",
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out empty string values from array', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'region',
|
||||
selectedValue: ['us-east', '', 'eu-west'],
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"region IN ['us-east', 'eu-west']",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Multiple siblings ---
|
||||
it('should join multiple sibling variables with AND', () => {
|
||||
const variables = {
|
||||
v2: makeDynamicVar({
|
||||
id: 'v2',
|
||||
dynamicVariablesAttribute: 'k8s.namespace.name',
|
||||
selectedValue: ['zeus', 'gene'],
|
||||
}),
|
||||
v3: makeDynamicVar({
|
||||
id: 'v3',
|
||||
dynamicVariablesAttribute: 'doc_op_type',
|
||||
selectedValue: 'test',
|
||||
}),
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
|
||||
"k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string when no variables are eligible', () => {
|
||||
const variables = {
|
||||
v1: makeDynamicVar({ id: 'v1' }), // same as current — skipped
|
||||
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }), // not DYNAMIC
|
||||
v3: makeDynamicVar({ id: 'v3', selectedValue: null }), // no value
|
||||
};
|
||||
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleVariableFetch', () => {
|
||||
const mockGetSnapshot = variableFetchStore.getSnapshot as jest.Mock;
|
||||
const mockComplete = onVariableFetchComplete as jest.Mock;
|
||||
const mockFailure = onVariableFetchFailure as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should no-op when name is undefined', () => {
|
||||
settleVariableFetch(undefined, 'complete');
|
||||
expect(mockGetSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockComplete).not.toHaveBeenCalled();
|
||||
expect(mockFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['idle', 'waiting', 'error', undefined] as const)(
|
||||
'should no-op when variable state is %s',
|
||||
(state) => {
|
||||
mockGetSnapshot.mockReturnValue({ states: { myVar: state } });
|
||||
settleVariableFetch('myVar', 'complete');
|
||||
expect(mockComplete).not.toHaveBeenCalled();
|
||||
expect(mockFailure).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should call onVariableFetchComplete when state is loading and outcome is complete', () => {
|
||||
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
|
||||
settleVariableFetch('myVar', 'complete');
|
||||
expect(mockComplete).toHaveBeenCalledWith('myVar');
|
||||
expect(mockFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onVariableFetchComplete when state is revalidating and outcome is complete', () => {
|
||||
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
|
||||
settleVariableFetch('myVar', 'complete');
|
||||
expect(mockComplete).toHaveBeenCalledWith('myVar');
|
||||
expect(mockFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onVariableFetchFailure when state is loading and outcome is failure', () => {
|
||||
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
|
||||
settleVariableFetch('myVar', 'failure');
|
||||
expect(mockFailure).toHaveBeenCalledWith('myVar');
|
||||
expect(mockComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onVariableFetchFailure when state is revalidating and outcome is failure', () => {
|
||||
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
|
||||
settleVariableFetch('myVar', 'failure');
|
||||
expect(mockFailure).toHaveBeenCalledWith('myVar');
|
||||
expect(mockComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
const FALLBACK_MESSAGE =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
|
||||
it('should return SOMETHING_WENT_WRONG when error is null', () => {
|
||||
expect(extractErrorMessage(null)).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should return the error message when it exists', () => {
|
||||
expect(extractErrorMessage({ message: 'Query timeout' })).toBe(
|
||||
'Query timeout',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return fallback when error object has no message property', () => {
|
||||
expect(extractErrorMessage({})).toBe(FALLBACK_MESSAGE);
|
||||
});
|
||||
|
||||
it('should return fallback when error.message is empty string', () => {
|
||||
expect(extractErrorMessage({ message: '' })).toBe(FALLBACK_MESSAGE);
|
||||
});
|
||||
|
||||
it('should return fallback when error.message is undefined', () => {
|
||||
expect(extractErrorMessage({ message: undefined })).toBe(FALLBACK_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { VariableItemProps } from '../VariableItem';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
export interface VariableSelectStrategy {
|
||||
handleChange(params: {
|
||||
value: string | string[];
|
||||
variableData: VariableItemProps['variableData'];
|
||||
onValueUpdate: VariableItemProps['onValueUpdate'];
|
||||
variableData: IDashboardVariable;
|
||||
optionsData: (string | number | boolean)[];
|
||||
allAvailableOptionStrings: string[];
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
}): void;
|
||||
}
|
||||
|
||||
@@ -17,19 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
|
||||
|
||||
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
|
||||
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
|
||||
useVariableFetchState: (): Record<string, unknown> => ({
|
||||
variableFetchCycleId: 0,
|
||||
variableFetchState: 'loading',
|
||||
isVariableSettled: false,
|
||||
isVariableFetching: true,
|
||||
hasVariableFetchedOnce: false,
|
||||
isVariableWaitingForDependencies: false,
|
||||
variableDependencyWaitMessage: '',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the getFieldValues API
|
||||
jest.mock('api/dynamicVariables/getFieldValues', () => ({
|
||||
getFieldValues: jest.fn(),
|
||||
@@ -108,7 +95,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
}
|
||||
}
|
||||
if (queryFn) {
|
||||
queryFn({ signal: undefined });
|
||||
queryFn();
|
||||
}
|
||||
}
|
||||
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
|
||||
@@ -247,7 +234,6 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
'2023-01-01T00:00:00Z',
|
||||
'2023-01-02T00:00:00Z',
|
||||
'',
|
||||
undefined, // signal
|
||||
);
|
||||
});
|
||||
|
||||
@@ -501,7 +487,6 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
'2023-01-01T00:00:00Z',
|
||||
'2023-01-02T00:00:00Z',
|
||||
'',
|
||||
undefined, // signal
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -49,11 +49,15 @@ const mockDashboard = {
|
||||
// Mock the dashboard provider with stable functions to prevent infinite loops
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
const mockSetVariablesToGetUpdated = jest.fn();
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
useDashboard: (): any => ({
|
||||
selectedDashboard: mockDashboard,
|
||||
setSelectedDashboard: mockSetSelectedDashboard,
|
||||
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
|
||||
variablesToGetUpdated: ['env'], // Stable initial value
|
||||
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: rgba(113, 144, 249, 0.1);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboard-icon-image {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dashboard-breadcrumbs {
|
||||
.dashboard-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { DashboardData } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { Base64Icons } from '../../DashboardSettings/General/utils';
|
||||
|
||||
import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
function DashboardBreadcrumbs(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedDashboard, listSortOrder } = useDashboard();
|
||||
|
||||
const selectedData = selectedDashboard
|
||||
? {
|
||||
...selectedDashboard.data,
|
||||
uuid: selectedDashboard.id,
|
||||
}
|
||||
: ({} as DashboardData);
|
||||
|
||||
const { title = '', image = Base64Icons[0] } = selectedData || {};
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}, [listSortOrder, safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img src={image} alt="dashboard-icon" className="dashboard-icon-image" />
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -1,15 +0,0 @@
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import './DashboardHeader.styles.scss';
|
||||
|
||||
function DashboardHeader(): JSX.Element {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardBreadcrumbs />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import BarChartTooltip from 'lib/uPlotV2/components/Tooltip/BarChartTooltip';
|
||||
import {
|
||||
BarTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const { children, isStackedBarChart, config, data, ...rest } = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart,
|
||||
config,
|
||||
});
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
const tooltipProps: BarTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
renderTooltip={renderTooltip}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export default function ChartWrapper({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
canPinTooltip = false,
|
||||
syncMode,
|
||||
syncKey,
|
||||
@@ -37,9 +36,6 @@ export default function ChartWrapper({
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
@@ -48,7 +44,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -64,7 +60,6 @@ export default function ChartWrapper({
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
showLegend={showLegend}
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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,7 +7,6 @@ interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
showTooltip?: boolean;
|
||||
showLegend?: boolean;
|
||||
timezone: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
@@ -18,7 +17,6 @@ interface BaseChartProps {
|
||||
interface UPlotBasedChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
data: uPlot.AlignedData;
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
@@ -28,20 +26,14 @@ interface UPlotBasedChartProps {
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
isQueriesMerged?: boolean;
|
||||
legendConfig: LegendConfig;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
export type ChartProps = TimeSeriesChartProps | BarChartProps;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { getInitialStackedBands, stack } from '../stackUtils';
|
||||
|
||||
describe('stackUtils', () => {
|
||||
describe('stack', () => {
|
||||
const neverOmit = (): boolean => false;
|
||||
|
||||
it('preserves time axis as first row', () => {
|
||||
const data: AlignedData = [
|
||||
[100, 200, 300],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[0]).toEqual([100, 200, 300]);
|
||||
});
|
||||
|
||||
it('stacks value series cumulatively (last = raw, first = total)', () => {
|
||||
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
|
||||
const data: AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3], // series 1
|
||||
[4, 5, 6], // series 2
|
||||
[7, 8, 9], // series 3
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
|
||||
expect(result[1]).toEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
|
||||
expect(result[2]).toEqual([11, 13, 15]); // 4+7, 5+8, 6+9
|
||||
expect(result[3]).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('treats null values as 0 when stacking', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, null],
|
||||
[null, 10],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[1]).toEqual([1, 10]); // total
|
||||
expect(result[2]).toEqual([0, 10]); // last series with null→0
|
||||
});
|
||||
|
||||
it('copies omitted series as-is without accumulating', () => {
|
||||
// Omit series 2 (index 2); series 1 and 3 are stacked.
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20], // series 1
|
||||
[100, 200], // series 2 - omitted
|
||||
[1, 2], // series 3
|
||||
];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { data: result } = stack(data, omitSeries2);
|
||||
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
|
||||
expect(result[1]).toEqual([11, 22]); // 10+1, 20+2
|
||||
expect(result[2]).toEqual([100, 200]); // copied, not stacked
|
||||
expect(result[3]).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('returns bands between consecutive visible series when none omitted', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
});
|
||||
|
||||
it('returns bands only between visible series when some are omitted', () => {
|
||||
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
|
||||
const data: AlignedData = [[0], [1], [2], [3], [4]];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { bands } = stack(data, omitSeries2);
|
||||
expect(bands).toEqual([{ series: [1, 3] }, { series: [3, 4] }]);
|
||||
});
|
||||
|
||||
it('returns empty bands when only one value series', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialStackedBands', () => {
|
||||
it('returns one band between each consecutive pair for seriesCount 3', () => {
|
||||
expect(getInitialStackedBands(3)).toEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for seriesCount 0 or 1', () => {
|
||||
expect(getInitialStackedBands(0)).toEqual([]);
|
||||
expect(getInitialStackedBands(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single band for seriesCount 2', () => {
|
||||
expect(getInitialStackedBands(2)).toEqual([{ series: [1, 2] }]);
|
||||
});
|
||||
|
||||
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
|
||||
const bands = getInitialStackedBands(5);
|
||||
expect(bands).toEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
{ series: [3, 4] },
|
||||
{ series: [4, 5] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stackSeries(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
|
||||
const stackedSeries = buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
return {
|
||||
data: [timeAxis, ...stackedSeries] as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildStackedSeriesParams {
|
||||
data: AlignedData;
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
*/
|
||||
function buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
|
||||
if (omit(seriesIndex)) {
|
||||
stackedSeries[seriesIndex - 1] = rawValues;
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands define fill between consecutive visible series for stacked appearance.
|
||||
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
function buildFillBands(
|
||||
seriesLength: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
|
||||
seriesLength,
|
||||
seriesIndex,
|
||||
omit,
|
||||
);
|
||||
if (nextVisibleSeriesIndex !== -1) {
|
||||
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return bands;
|
||||
}
|
||||
|
||||
function findNextVisibleSeriesIndex(
|
||||
seriesLength: number,
|
||||
afterIndex: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): number {
|
||||
for (let i = afterIndex + 1; i < seriesLength; i++) {
|
||||
if (!omit(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stack(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
|
||||
const stackedSeries = buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
return {
|
||||
data: [timeAxis, ...stackedSeries] as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildStackedSeriesParams {
|
||||
data: AlignedData;
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
*/
|
||||
function buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
|
||||
if (omit(seriesIndex)) {
|
||||
stackedSeries[seriesIndex - 1] = rawValues;
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands define fill between consecutive visible series for stacked appearance.
|
||||
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
function buildFillBands(
|
||||
seriesLength: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
|
||||
seriesLength,
|
||||
seriesIndex,
|
||||
omit,
|
||||
);
|
||||
if (nextVisibleSeriesIndex !== -1) {
|
||||
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return bands;
|
||||
}
|
||||
|
||||
function findNextVisibleSeriesIndex(
|
||||
seriesLength: number,
|
||||
afterIndex: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): number {
|
||||
for (let i = afterIndex + 1; i < seriesLength; i++) {
|
||||
if (!omit(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { UseBarChartStackingParams } from '../useBarChartStacking';
|
||||
import { useBarChartStacking } from '../useBarChartStacking';
|
||||
|
||||
type MockConfig = { addHook: jest.Mock };
|
||||
|
||||
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
|
||||
return (c as unknown) as UseBarChartStackingParams['config'];
|
||||
}
|
||||
|
||||
function createMockConfig(): {
|
||||
config: MockConfig;
|
||||
invokeSetData: (plot: uPlot) => void;
|
||||
invokeSetSeries: (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
) => void;
|
||||
removeSetData: jest.Mock;
|
||||
removeSetSeries: jest.Mock;
|
||||
} {
|
||||
let setDataHandler: ((plot: uPlot) => void) | null = null;
|
||||
let setSeriesHandler:
|
||||
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
|
||||
| null = null;
|
||||
|
||||
const removeSetData = jest.fn();
|
||||
const removeSetSeries = jest.fn();
|
||||
|
||||
const addHook = jest.fn(
|
||||
(
|
||||
hookName: string,
|
||||
handler: (plot: uPlot, ...args: unknown[]) => void,
|
||||
): (() => void) => {
|
||||
if (hookName === 'setData') {
|
||||
setDataHandler = handler as (plot: uPlot) => void;
|
||||
return removeSetData;
|
||||
}
|
||||
if (hookName === 'setSeries') {
|
||||
setSeriesHandler = handler as (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: uPlot.Series,
|
||||
) => void;
|
||||
return removeSetSeries;
|
||||
}
|
||||
return jest.fn();
|
||||
},
|
||||
);
|
||||
|
||||
const config: MockConfig = { addHook };
|
||||
|
||||
const invokeSetData = (plot: uPlot): void => {
|
||||
setDataHandler?.(plot);
|
||||
};
|
||||
|
||||
const invokeSetSeries = (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
): void => {
|
||||
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
invokeSetData,
|
||||
invokeSetSeries,
|
||||
removeSetData,
|
||||
removeSetSeries,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
|
||||
return ({
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
series: [{ show: true }, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown) as uPlot;
|
||||
}
|
||||
|
||||
describe('useBarChartStacking', () => {
|
||||
it('returns data as-is when isStackedBarChart is false', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[100, 200],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('returns data as-is when config is null and isStackedBarChart is true', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[4, 5],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
// Still returns stacked data (computed in useMemo); no hooks registered
|
||||
expect(result.current[0]).toEqual([0, 1]);
|
||||
expect(result.current[1]).toEqual([5, 7]); // stacked
|
||||
expect(result.current[2]).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current[0]).toEqual([0, 1, 2]);
|
||||
expect(result.current[1]).toEqual([12, 15, 18]); // s1+s2+s3
|
||||
expect(result.current[2]).toEqual([11, 13, 15]); // s2+s3
|
||||
expect(result.current[3]).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('returns data as-is when only one value series (no stacking needed)', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toEqual(data);
|
||||
});
|
||||
|
||||
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
|
||||
expect(config.addHook).toHaveBeenCalledWith(
|
||||
'setSeries',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register hooks when isStackedBarChart is false', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const { config, removeSetData, removeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSetData).toHaveBeenCalled();
|
||||
expect(removeSetSeries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-stacks and updates plot when setData hook is invoked', () => {
|
||||
const { config, invokeSetData } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[5, 7, 9],
|
||||
[4, 5, 6],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetData(plot);
|
||||
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
expect(plot.addBand).toHaveBeenCalled();
|
||||
expect(plot.setData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
[0, 1, 2],
|
||||
expect.any(Array), // stacked row 1
|
||||
expect.any(Array), // stacked row 2
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20],
|
||||
[5, 10],
|
||||
];
|
||||
// Plot data must match unstacked length so canApplyStacking passes
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1],
|
||||
[15, 30],
|
||||
[5, 10],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetSeries(plot, 1, { show: false });
|
||||
|
||||
expect(plot.setData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-stack when setSeries is called with focus option', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const plot = createMockPlot();
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
(plot.setData as jest.Mock).mockClear();
|
||||
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
focus: jest.Mock;
|
||||
};
|
||||
|
||||
function createMockElement(): MockHTMLElement {
|
||||
return {
|
||||
scrollIntoView: jest.fn(),
|
||||
focus: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
expect(mockElement.scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(mockElement.focus).toHaveBeenCalled();
|
||||
expect(setToScrollWidgetId).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
expect(mockElement.scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(mockElement.focus).not.toHaveBeenCalled();
|
||||
expect(setToScrollWidgetId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../charts/utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
applyStackingToChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
if (!has(opts, 'focus')) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBarChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
isStackedBarChart?: boolean;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles stacking for bar charts: computes initial stacked data and re-stacks
|
||||
* when data or series visibility changes (e.g. legend toggles).
|
||||
*/
|
||||
export function useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart = false,
|
||||
config,
|
||||
}: UseBarChartStackingParams): uPlot.AlignedData {
|
||||
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = isStackedBarChart ? data : null;
|
||||
|
||||
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (!isStackedBarChart || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
const { data: stacked } = stackSeries(data, noSeriesHidden);
|
||||
return stacked;
|
||||
}, [data, isStackedBarChart]);
|
||||
|
||||
const applyStackingToChart = useCallback((plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStackedBarChart || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
|
||||
}, [isStackedBarChart, config, applyStackingToChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
* requests it via `toScrollWidgetId`.
|
||||
*
|
||||
* Intended for use in panel components that render a single widget.
|
||||
*/
|
||||
export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
widgetContainerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
widgetContainerRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widgetId, widgetContainerRef]);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import './ChartLayout.styles.scss';
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
showLegend?: boolean;
|
||||
legendComponent: (legendPerSet: number) => React.ReactNode;
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
@@ -22,7 +20,6 @@ export interface ChartLayoutProps {
|
||||
config: UPlotConfigBuilder;
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
@@ -33,15 +30,6 @@ export default function ChartLayout({
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
if (!showLegend) {
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
@@ -54,7 +42,7 @@ export default function ChartLayout({
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,17 +60,15 @@ export default function ChartLayout({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="chart-layout__legend-wrapper"
|
||||
style={{
|
||||
height: chartDimensions.legendHeight,
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
</div>
|
||||
{layoutChildren}
|
||||
</div>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import BarChart from '../../charts/BarChart/BarChart';
|
||||
import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
panelMode,
|
||||
queryResponse,
|
||||
widget,
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [queryResponse]);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
onClose,
|
||||
menuItemsConfig,
|
||||
clickHandlerWithContextMenu,
|
||||
} = usePanelContextMenu({
|
||||
widget,
|
||||
queryResponse,
|
||||
});
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareBarPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
currentQuery: widget.query,
|
||||
onClick: clickHandlerWithContextMenu,
|
||||
onDragSelect,
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale: minTimeScale,
|
||||
maxTimeScale: maxTimeScale,
|
||||
});
|
||||
}, [
|
||||
widget,
|
||||
isDarkMode,
|
||||
queryResponse?.data?.payload,
|
||||
clickHandlerWithContextMenu,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!queryResponse?.data?.payload) {
|
||||
return [];
|
||||
}
|
||||
return prepareBarPanelData(queryResponse?.data?.payload);
|
||||
}, [queryResponse?.data?.payload]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
if (!isFullViewMode) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
onCancel={onToggleModelHandler}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isFullViewMode,
|
||||
config,
|
||||
chartData,
|
||||
widget.yAxisUnit,
|
||||
onToggleModelHandler,
|
||||
]);
|
||||
|
||||
const onPlotDestroy = useCallback(() => {
|
||||
uPlotRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onPlotRef = useCallback((plot: uPlot | null): void => {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
}}
|
||||
plotRef={onPlotRef}
|
||||
onDestroy={onPlotDestroy}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone.value}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</BarChart>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarPanel;
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
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 { AlignedData } from 'uplot';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
|
||||
export function prepareBarPanelData(
|
||||
apiResponse: MetricRangePayloadProps,
|
||||
): AlignedData {
|
||||
const seriesList = apiResponse?.data?.result || [];
|
||||
const timestampArr = getXAxisTimestamps(seriesList);
|
||||
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
|
||||
return [timestampArr, ...yAxisValuesArr];
|
||||
}
|
||||
|
||||
export function prepareBarPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
panelMode,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
builder.setCursor({
|
||||
focus: {
|
||||
prox: 1e3,
|
||||
},
|
||||
});
|
||||
|
||||
if (widget.stackedBarChart) {
|
||||
const seriesCount = (apiResponse?.data?.result?.length ?? 0) + 1; // +1 for 1-based uPlot series indices
|
||||
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(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = currentQuery
|
||||
? getLegend(series, currentQuery, baseLabelName)
|
||||
: baseLabelName;
|
||||
|
||||
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
label: label,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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;
|
||||
@@ -1,223 +0,0 @@
|
||||
/* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
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,6 +26,7 @@ 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>();
|
||||
@@ -34,7 +35,16 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -83,7 +83,7 @@ export const prepareUPlotConfig = ({
|
||||
drawStyle: DrawStyle.Line,
|
||||
label: label,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: true,
|
||||
spanGaps: false,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { GraphVisibilityState } from '../../types';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from '../legendVisibilityUtils';
|
||||
|
||||
describe('legendVisibilityUtils', () => {
|
||||
const storageKey = LOCALSTORAGE.GRAPH_VISIBILITY_STATES;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.spyOn(window.localStorage.__proto__, 'getItem');
|
||||
jest.spyOn(window.localStorage.__proto__, 'setItem');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getStoredSeriesVisibility', () => {
|
||||
it('returns null when there is no stored visibility state', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith(storageKey);
|
||||
});
|
||||
|
||||
it('returns null when widget has no stored dataIndex', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns visibility array by index when widget state exists', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'widget-2',
|
||||
dataIndex: [{ label: 'Errors', show: true }],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns visibility by index including duplicate labels', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON in localStorage', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when widget id is not found', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'another-widget',
|
||||
dataIndex: [{ label: 'CPU', show: true }],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSeriesVisibilityToLocalStorage', () => {
|
||||
it('creates new visibility state when none exists', () => {
|
||||
const seriesVisibility = [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', seriesVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a new widget entry when other widgets already exist', () => {
|
||||
const existing: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-existing',
|
||||
dataIndex: [{ label: 'Errors', show: true }],
|
||||
},
|
||||
];
|
||||
localStorage.setItem(storageKey, JSON.stringify(existing));
|
||||
|
||||
const newVisibility = [{ label: 'CPU', show: false }];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-new', newVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-new');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual([{ label: 'CPU', show: false }]);
|
||||
});
|
||||
|
||||
it('updates existing widget visibility when entry already exists', () => {
|
||||
const initialVisibility: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(initialVisibility));
|
||||
|
||||
const updatedVisibility = [
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', updatedVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual([
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('silently handles malformed existing JSON without throwing', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
expect(() =>
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'CPU', show: true },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('when existing JSON is malformed, overwrites with valid data for the widget', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
const expected = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
storageKey,
|
||||
JSON.stringify(expected),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves other widgets when updating one widget', () => {
|
||||
const existing: GraphVisibilityState[] = [
|
||||
{ name: 'widget-a', dataIndex: [{ label: 'A', show: true }] },
|
||||
{ name: 'widget-b', dataIndex: [{ label: 'B', show: false }] },
|
||||
];
|
||||
localStorage.setItem(storageKey, JSON.stringify(existing));
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-b', [
|
||||
{ label: 'B', show: 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', () => {
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'CPU', show: true },
|
||||
]);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
storageKey,
|
||||
expect.any(String),
|
||||
);
|
||||
const [_, value] = (localStorage.setItem as jest.Mock).mock.calls[0];
|
||||
expect((): void => JSON.parse(value)).not.toThrow();
|
||||
expect(JSON.parse(value)).toEqual([
|
||||
{ name: 'widget-1', dataIndex: [{ label: 'CPU', show: true }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores empty dataIndex when seriesVisibility is empty', () => {
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', []);
|
||||
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw ?? '[]');
|
||||
expect(parsed).toEqual([{ name: 'widget-1', dataIndex: [] }]);
|
||||
expect(getStoredSeriesVisibility('widget-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,9 @@ 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;
|
||||
@@ -40,10 +40,8 @@ export function buildBaseConfig({
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
onDragSelect,
|
||||
@@ -90,7 +88,7 @@ export function buildBaseConfig({
|
||||
max: undefined,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
thresholds: thresholdOptions,
|
||||
// thresholds,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,13 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
|
||||
|
||||
/**
|
||||
* Retrieves the stored series visibility for a specific widget from localStorage by index.
|
||||
* Index 0 is the x-axis (time); indices 1, 2, ... are data series (same order as uPlot plot.series).
|
||||
* Retrieves the visibility map for a specific widget from localStorage
|
||||
* @param widgetId - The unique identifier of the widget
|
||||
* @returns visibility[i] = show state for series at index i, or null if not found
|
||||
* @returns A Map of series labels to their visibility state, or null if not found
|
||||
*/
|
||||
export function getStoredSeriesVisibility(
|
||||
widgetId: string,
|
||||
): SeriesVisibilityItem[] | null {
|
||||
): Map<string, boolean> | null {
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
|
||||
@@ -25,12 +24,8 @@ export function getStoredSeriesVisibility(
|
||||
return null;
|
||||
}
|
||||
|
||||
return widgetState.dataIndex;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
// If the stored data is malformed, remove it
|
||||
localStorage.removeItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
}
|
||||
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
|
||||
} catch {
|
||||
// Silently handle parsing errors - fall back to default visibility
|
||||
return null;
|
||||
}
|
||||
@@ -40,31 +35,40 @@ export function updateSeriesVisibilityToLocalStorage(
|
||||
widgetId: string,
|
||||
seriesVisibility: SeriesVisibilityItem[],
|
||||
): void {
|
||||
let visibilityStates: GraphVisibilityState[] = [];
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
visibilityStates = JSON.parse(storedData || '[]');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
visibilityStates = [];
|
||||
|
||||
let visibilityStates: GraphVisibilityState[];
|
||||
|
||||
if (!storedData) {
|
||||
visibilityStates = [
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
visibilityStates = JSON.parse(storedData);
|
||||
}
|
||||
}
|
||||
const widgetState = visibilityStates.find((state) => state.name === widgetId);
|
||||
const widgetState = visibilityStates.find((state) => state.name === widgetId);
|
||||
|
||||
if (widgetState) {
|
||||
widgetState.dataIndex = seriesVisibility;
|
||||
} else {
|
||||
visibilityStates = [
|
||||
...visibilityStates,
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!widgetState) {
|
||||
visibilityStates = [
|
||||
...visibilityStates,
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
widgetState.dataIndex = seriesVisibility;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
|
||||
JSON.stringify(visibilityStates),
|
||||
);
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
|
||||
JSON.stringify(visibilityStates),
|
||||
);
|
||||
} catch {
|
||||
// Silently handle parsing errors - fall back to default visibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
.forgot-password-title {
|
||||
font-family: var(--label-large-600-font-family);
|
||||
font-size: var(--label-large-600-font-size);
|
||||
font-weight: var(--label-large-600-font-weight);
|
||||
letter-spacing: var(--label-large-600-letter-spacing);
|
||||
line-height: 1.45;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forgot-password-description {
|
||||
font-family: var(--paragraph-base-400-font-family);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: 317px;
|
||||
}
|
||||
|
||||
.forgot-password-form {
|
||||
width: 100%;
|
||||
|
||||
// Label styling
|
||||
.forgot-password-label {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
|
||||
.lightMode & {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
// Parent container for fields
|
||||
.forgot-password-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.ant-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
> .forgot-password-back-button,
|
||||
> .login-submit-btn {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password-back-button {
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--l3-border);
|
||||
border-color: var(--l3-border);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { ArrowLeft, Mail } from '@signozhq/icons';
|
||||
|
||||
interface SuccessScreenProps {
|
||||
onBackToLogin: () => void;
|
||||
}
|
||||
|
||||
function SuccessScreen({ onBackToLogin }: SuccessScreenProps): JSX.Element {
|
||||
return (
|
||||
<div className="login-form-container">
|
||||
<div className="forgot-password-form">
|
||||
<div className="login-form-header">
|
||||
<div className="login-form-emoji">
|
||||
<Mail size={32} />
|
||||
</div>
|
||||
<h4 className="forgot-password-title">Check your email</h4>
|
||||
<p className="forgot-password-description">
|
||||
We've sent a password reset link to your email. Please check your
|
||||
inbox and follow the instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-form-actions forgot-password-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
type="button"
|
||||
data-testid="back-to-login"
|
||||
className="login-submit-btn"
|
||||
onClick={onBackToLogin}
|
||||
prefixIcon={<ArrowLeft size={12} />}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessScreen;
|
||||
@@ -1,402 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
createErrorResponse,
|
||||
handleInternalServerError,
|
||||
rest,
|
||||
server,
|
||||
} from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
import ForgotPassword, { ForgotPasswordRouteState } from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const FORGOT_PASSWORD_ENDPOINT = '*/api/v2/factor_password/forgot';
|
||||
|
||||
// Mock data
|
||||
const mockSingleOrg: OrgSessionContext[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockMultipleOrgs: OrgSessionContext[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Organization One',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: 'Organization Two',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_EMAIL = 'jest.test@signoz.io';
|
||||
|
||||
const defaultProps: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
const multiOrgProps: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
describe('ForgotPassword Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders forgot password form with all required elements', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/forgot your password\?/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/send a reset link to your inbox/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('forgot-password-submit')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('forgot-password-back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills email from props', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toHaveValue(TEST_EMAIL);
|
||||
});
|
||||
|
||||
it('disables email input field', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show organization dropdown for single org', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Organization Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables submit button when email is provided with single org', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Organizations', () => {
|
||||
it('shows organization dropdown when multiple orgs exist', () => {
|
||||
render(<ForgotPassword {...multiOrgProps} />);
|
||||
|
||||
expect(screen.getByTestId('orgId')).toBeInTheDocument();
|
||||
expect(screen.getByText('Organization Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables submit button when org is not selected', () => {
|
||||
const propsWithoutOrgId: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithoutOrgId} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit button after selecting an organization', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ForgotPassword {...multiOrgProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Click on the dropdown to reveal the options
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByText('Organization One'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('pre-selects organization when orgId is provided', () => {
|
||||
const propsWithOrgId: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgId: 'org-1',
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithOrgId} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Success', () => {
|
||||
it('successfully submits forgot password request and shows success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/we've sent a password reset link/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back to login button on success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when clicking back to login on success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
|
||||
|
||||
const backToLoginButton = screen.getByTestId('back-to-login');
|
||||
await user.click(backToLoginButton);
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Error Handling', () => {
|
||||
it('displays error message when forgot password API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
FORGOT_PASSWORD_ENDPOINT,
|
||||
createErrorResponse(400, 'USER_NOT_FOUND', 'User not found'),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error message when API returns server error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(rest.post(FORGOT_PASSWORD_ENDPOINT, handleInternalServerError));
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/internal server error occurred/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears error message on new submission attempt', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let requestCount = 0;
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
|
||||
|
||||
// Click submit again
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/user not found/i)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('redirects to login when clicking back button on form', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const backButton = screen.getByTestId('forgot-password-back');
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('shows loading state during API call', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Button should show loading state
|
||||
expect(await screen.findByText(/sending\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables submit button during loading', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty email gracefully', () => {
|
||||
const propsWithEmptyEmail: ForgotPasswordRouteState = {
|
||||
email: '',
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithEmptyEmail} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles whitespace-only email', () => {
|
||||
const propsWithWhitespaceEmail: ForgotPasswordRouteState = {
|
||||
email: ' ',
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithWhitespaceEmail} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles empty orgs array by disabling submission', () => {
|
||||
const propsWithNoOrgs: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: [],
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithNoOrgs} />);
|
||||
|
||||
// Should not show org dropdown
|
||||
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
|
||||
// Submit should be disabled because no orgId can be determined
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { ArrowLeft, ArrowRight } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Select } from 'antd';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { useForgotPassword } from 'api/generated/services/users';
|
||||
import { AxiosError } from 'axios';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
import SuccessScreen from './SuccessScreen';
|
||||
|
||||
import './ForgotPassword.styles.scss';
|
||||
import 'container/Login/Login.styles.scss';
|
||||
|
||||
type FormValues = {
|
||||
email: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordRouteState = {
|
||||
email: string;
|
||||
orgId?: string;
|
||||
orgs: OrgSessionContext[];
|
||||
};
|
||||
|
||||
function ForgotPassword({
|
||||
email,
|
||||
orgId,
|
||||
orgs,
|
||||
}: ForgotPasswordRouteState): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const {
|
||||
mutate: forgotPasswordMutate,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
error: mutationError,
|
||||
} = useForgotPassword();
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (!mutationError) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
ErrorResponseHandlerV2(mutationError as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
return apiError as APIError;
|
||||
}
|
||||
}, [mutationError]);
|
||||
|
||||
const initialOrgId = useMemo((): string | undefined => {
|
||||
if (orgId) {
|
||||
return orgId;
|
||||
}
|
||||
|
||||
if (orgs.length === 1) {
|
||||
return orgs[0]?.id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [orgId, orgs]);
|
||||
|
||||
const watchedEmail = Form.useWatch('email', form);
|
||||
const selectedOrgId = Form.useWatch('orgId', form);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
email,
|
||||
orgId: initialOrgId,
|
||||
});
|
||||
}, [email, form, initialOrgId]);
|
||||
|
||||
const hasMultipleOrgs = orgs.length > 1;
|
||||
|
||||
const isSubmitEnabled = useMemo((): boolean => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!watchedEmail?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure we have an orgId (either selected from dropdown or the initial one)
|
||||
const currentOrgId = hasMultipleOrgs ? selectedOrgId : initialOrgId;
|
||||
return Boolean(currentOrgId);
|
||||
}, [watchedEmail, selectedOrgId, isLoading, initialOrgId, hasMultipleOrgs]);
|
||||
|
||||
const handleSubmit = useCallback((): void => {
|
||||
const values = form.getFieldsValue();
|
||||
const currentOrgId = hasMultipleOrgs ? values.orgId : initialOrgId;
|
||||
|
||||
if (!currentOrgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the forgot password API
|
||||
forgotPasswordMutate({
|
||||
data: {
|
||||
email: values.email,
|
||||
orgId: currentOrgId,
|
||||
frontendBaseURL: window.location.origin,
|
||||
},
|
||||
});
|
||||
}, [form, forgotPasswordMutate, initialOrgId, hasMultipleOrgs]);
|
||||
|
||||
const handleBackToLogin = useCallback((): void => {
|
||||
history.push(ROUTES.LOGIN);
|
||||
}, []);
|
||||
|
||||
// Success screen
|
||||
if (isSuccess) {
|
||||
return <SuccessScreen onBackToLogin={handleBackToLogin} />;
|
||||
}
|
||||
|
||||
// Form screen
|
||||
return (
|
||||
<div className="login-form-container">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
className="forgot-password-form"
|
||||
initialValues={{
|
||||
email,
|
||||
orgId: initialOrgId,
|
||||
}}
|
||||
>
|
||||
<div className="login-form-header">
|
||||
<div className="login-form-emoji">
|
||||
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
|
||||
</div>
|
||||
<h4 className="forgot-password-title">Forgot your password?</h4>
|
||||
<p className="forgot-password-description">
|
||||
Send a reset link to your inbox and get back to monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-form-card">
|
||||
<div className="forgot-password-field">
|
||||
<label className="forgot-password-label" htmlFor="forgotPasswordEmail">
|
||||
Email address
|
||||
</label>
|
||||
<Form.Item name="email">
|
||||
<Input
|
||||
type="email"
|
||||
id="forgotPasswordEmail"
|
||||
data-testid="email"
|
||||
required
|
||||
disabled
|
||||
className="login-form-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{hasMultipleOrgs && (
|
||||
<div className="forgot-password-field">
|
||||
<label className="forgot-password-label" htmlFor="orgId">
|
||||
Organization Name
|
||||
</label>
|
||||
<Form.Item
|
||||
name="orgId"
|
||||
rules={[{ required: true, message: 'Please select your organization' }]}
|
||||
>
|
||||
<Select
|
||||
id="orgId"
|
||||
data-testid="orgId"
|
||||
className="login-form-input login-form-select-no-border"
|
||||
placeholder="Select your organization"
|
||||
options={orgs.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name || 'default',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && <AuthError error={errorMessage} />}
|
||||
|
||||
<div className="login-form-actions forgot-password-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
type="button"
|
||||
data-testid="forgot-password-back"
|
||||
className="forgot-password-back-button"
|
||||
onClick={handleBackToLogin}
|
||||
prefixIcon={<ArrowLeft size={12} />}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!isSubmitEnabled}
|
||||
loading={isLoading}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
type="submit"
|
||||
data-testid="forgot-password-submit"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -6,12 +6,10 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { getVariableReferencesInQuery } from 'lib/dashboardVariables/variableReference';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
@@ -55,6 +53,7 @@ function GridCardGraph({
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
widgetsByDynamicVariableId,
|
||||
}: GridCardGraphProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
@@ -65,8 +64,8 @@ function GridCardGraph({
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
setDashboardQueryRangeCalled,
|
||||
variablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@@ -118,25 +117,10 @@ function GridCardGraph({
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
const referencedVariableNames = useMemo(() => {
|
||||
if (!variables || !updatedQuery) {
|
||||
return [];
|
||||
}
|
||||
const allNames = Object.values(variables)
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
return getVariableReferencesInQuery(updatedQuery, allNames);
|
||||
}, [updatedQuery, variables]);
|
||||
|
||||
const isEmptyWidget =
|
||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||
|
||||
const isPanelWaitingOnAnyVariable = useIsPanelWaitingOnVariable(
|
||||
referencedVariableNames,
|
||||
);
|
||||
|
||||
const queryEnabledCondition =
|
||||
isVisible && !isEmptyWidget && isQueryEnabled && !isPanelWaitingOnAnyVariable;
|
||||
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
@@ -193,6 +177,27 @@ function GridCardGraph({
|
||||
[requestData.query],
|
||||
);
|
||||
|
||||
// Bring back dependency on variable chaining for panels to refetch,
|
||||
// but only for non-dynamic variables. We derive a stable token from
|
||||
// the head of the variablesToGetUpdated queue when it's non-dynamic.
|
||||
const nonDynamicVariableChainToken = useMemo(() => {
|
||||
if (!variablesToGetUpdated || variablesToGetUpdated.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!variables) {
|
||||
return undefined;
|
||||
}
|
||||
const headName = variablesToGetUpdated[0];
|
||||
const variableObj = Object.values(variables).find(
|
||||
(variable) => variable?.name === headName,
|
||||
);
|
||||
if (variableObj && variableObj.type !== 'DYNAMIC') {
|
||||
return headName;
|
||||
}
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variablesToGetUpdated, variables]);
|
||||
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
@@ -219,7 +224,11 @@ function GridCardGraph({
|
||||
requestData,
|
||||
variables
|
||||
? Object.entries(variables).reduce((acc, [id, variable]) => {
|
||||
if (variable.name && referencedVariableNames.includes(variable.name)) {
|
||||
if (
|
||||
variable.type !== 'DYNAMIC' ||
|
||||
(widgetsByDynamicVariableId?.[variable.id] &&
|
||||
widgetsByDynamicVariableId?.[variable.id].includes(widget.id))
|
||||
) {
|
||||
return { ...acc, [id]: variable.selectedValue };
|
||||
}
|
||||
return acc;
|
||||
@@ -228,6 +237,9 @@ function GridCardGraph({
|
||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||
: []),
|
||||
// Include non-dynamic variable chaining token to drive refetches
|
||||
// only when a non-dynamic variable is at the head of the queue
|
||||
...(nonDynamicVariableChainToken ? [nonDynamicVariableChainToken] : []),
|
||||
],
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
@@ -240,7 +252,7 @@ function GridCardGraph({
|
||||
return failureCount < 2;
|
||||
},
|
||||
keepPreviousData: true,
|
||||
enabled: queryEnabledCondition,
|
||||
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
|
||||
refetchOnMount: false,
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
@@ -307,7 +319,7 @@ function GridCardGraph({
|
||||
threshold={threshold}
|
||||
headerMenuList={menuList}
|
||||
isFetchingResponse={
|
||||
queryResponse.isFetching || isPanelWaitingOnAnyVariable
|
||||
queryResponse.isFetching || variablesToGetUpdated.length > 0
|
||||
}
|
||||
setRequestData={setRequestData}
|
||||
onClickHandler={onClickHandler}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface GridCardGraphProps {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
widgetsByDynamicVariableId?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { themeColors } from 'constants/theme';
|
||||
import { DEFAULT_ROW_NAME } from 'container/DashboardContainer/DashboardDescription/utils';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynamicVariableId';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -101,6 +102,8 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
Record<string, { widgets: Layout[]; collapsed: boolean }>
|
||||
>({});
|
||||
|
||||
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPanelMap(panelMap);
|
||||
}, [panelMap]);
|
||||
@@ -614,6 +617,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
onDragSelect={onDragSelect}
|
||||
dataAvailable={checkIfDataExists}
|
||||
enableDrillDown={enableDrillDown}
|
||||
widgetsByDynamicVariableId={widgetsByDynamicVariableId}
|
||||
/>
|
||||
</Card>
|
||||
</CardContainer>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
@@ -26,20 +27,12 @@ import {
|
||||
} from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { CollapseProps } from 'antd/lib';
|
||||
import {
|
||||
useCreateIngestionKey,
|
||||
useCreateIngestionKeyLimit,
|
||||
useDeleteIngestionKey,
|
||||
useDeleteIngestionKeyLimit,
|
||||
useGetIngestionKeys,
|
||||
useSearchIngestionKeys,
|
||||
useUpdateIngestionKey,
|
||||
useUpdateIngestionKeyLimit,
|
||||
} from 'api/generated/services/gateway';
|
||||
import {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import createIngestionKeyApi from 'api/IngestionKeys/createIngestionKey';
|
||||
import deleteIngestionKey from 'api/IngestionKeys/deleteIngestionKey';
|
||||
import createLimitForIngestionKeyApi from 'api/IngestionKeys/limits/createLimitsForKey';
|
||||
import deleteLimitsForIngestionKey from 'api/IngestionKeys/limits/deleteLimitsForIngestionKey';
|
||||
import updateLimitForIngestionKeyApi from 'api/IngestionKeys/limits/updateLimitsForIngestionKey';
|
||||
import updateIngestionKey from 'api/IngestionKeys/updateIngestionKey';
|
||||
import { AxiosError } from 'axios';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
@@ -51,6 +44,7 @@ import ROUTES from 'constants/routes';
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
@@ -72,12 +66,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import {
|
||||
AddLimitProps,
|
||||
LimitProps,
|
||||
UpdateLimitProps,
|
||||
} from 'types/api/ingestionKeys/limits/types';
|
||||
import { PaginationProps } from 'types/api/ingestionKeys/types';
|
||||
import {
|
||||
IngestionKeyProps,
|
||||
PaginationProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
@@ -88,10 +86,6 @@ const { Option } = Select;
|
||||
|
||||
const BYTES = 1073741824;
|
||||
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
const SEARCH_PAGE_SIZE = 100;
|
||||
const FIRST_PAGE = 1;
|
||||
|
||||
const COUNT_MULTIPLIER = {
|
||||
thousand: 1000,
|
||||
million: 1000000,
|
||||
@@ -117,8 +111,6 @@ export const showErrorNotification = (
|
||||
): void => {
|
||||
notifications.error({
|
||||
message: err.message || SOMETHING_WENT_WRONG,
|
||||
description: (err as AxiosError<RenderErrorResponseDTO>).response?.data?.error
|
||||
?.message,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -171,20 +163,15 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>([]);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isEditAddLimitOpen, setIsEditAddLimitOpen] = useState(false);
|
||||
const [
|
||||
activeAPIKey,
|
||||
setActiveAPIKey,
|
||||
] = useState<GatewaytypesIngestionKeyDTO | null>(null);
|
||||
const [activeAPIKey, setActiveAPIKey] = useState<IngestionKeyProps | null>();
|
||||
const [activeSignal, setActiveSignal] = useState<LimitProps | null>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [dataSource, setDataSource] = useState<GatewaytypesIngestionKeyDTO[]>(
|
||||
[],
|
||||
);
|
||||
const [dataSource, setDataSource] = useState<IngestionKeyProps[]>([]);
|
||||
const [paginationParams, setPaginationParams] = useState<PaginationProps>({
|
||||
page: FIRST_PAGE,
|
||||
per_page: INITIAL_PAGE_SIZE,
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
});
|
||||
|
||||
const [totalIngestionKeys, setTotalIngestionKeys] = useState(0);
|
||||
@@ -199,7 +186,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [
|
||||
createLimitForIngestionKeyError,
|
||||
setCreateLimitForIngestionKeyError,
|
||||
] = useState<string | null>(null);
|
||||
] = useState<ErrorResponse | null>(null);
|
||||
|
||||
const [
|
||||
hasUpdateLimitForIngestionKeyError,
|
||||
@@ -209,7 +196,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [
|
||||
updateLimitForIngestionKeyError,
|
||||
setUpdateLimitForIngestionKeyError,
|
||||
] = useState<string | null>(null);
|
||||
] = useState<ErrorResponse | null>(null);
|
||||
|
||||
const { t } = useTranslation(['ingestionKeys']);
|
||||
|
||||
@@ -229,11 +216,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
handleFormReset();
|
||||
};
|
||||
|
||||
const showDeleteModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
const showDeleteModal = (apiKey: IngestionKeyProps): void => {
|
||||
setActiveAPIKey(apiKey);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
@@ -250,11 +233,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const showEditModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
const showEditModal = (apiKey: IngestionKeyProps): void => {
|
||||
setActiveAPIKey(apiKey);
|
||||
handleFormReset();
|
||||
setUpdatedTags(apiKey.tags || []);
|
||||
@@ -269,10 +248,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
const showAddModal = (): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
setUpdatedTags([]);
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(true);
|
||||
@@ -283,62 +258,27 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
setActiveSignal(null);
|
||||
};
|
||||
|
||||
// Use search API when searchText is present, otherwise use normal get API
|
||||
const isSearching = searchText.length > 0;
|
||||
|
||||
const {
|
||||
data: ingestionKeysData,
|
||||
isLoading: isLoadingGet,
|
||||
isRefetching: isRefetchingGet,
|
||||
refetch: refetchGetAPIKeys,
|
||||
error: getError,
|
||||
isError: isGetError,
|
||||
} = useGetIngestionKeys(
|
||||
{
|
||||
...paginationParams,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !isSearching,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchIngestionKeysData,
|
||||
isLoading: isLoadingSearch,
|
||||
isRefetching: isRefetchingSearch,
|
||||
refetch: refetchSearchAPIKeys,
|
||||
error: searchError,
|
||||
isError: isSearchError,
|
||||
} = useSearchIngestionKeys(
|
||||
{
|
||||
page: FIRST_PAGE,
|
||||
per_page: SEARCH_PAGE_SIZE,
|
||||
name: searchText,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: isSearching,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Use the appropriate data based on which API is active
|
||||
const ingestionKeys = isSearching
|
||||
? searchIngestionKeysData
|
||||
: ingestionKeysData;
|
||||
const isLoading = isSearching ? isLoadingSearch : isLoadingGet;
|
||||
const isRefetching = isSearching ? isRefetchingSearch : isRefetchingGet;
|
||||
const refetchAPIKeys = isSearching ? refetchSearchAPIKeys : refetchGetAPIKeys;
|
||||
const error = isSearching ? searchError : getError;
|
||||
const isError = isSearching ? isSearchError : isGetError;
|
||||
data: IngestionKeys,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch: refetchAPIKeys,
|
||||
error,
|
||||
isError,
|
||||
} = useGetAllIngestionsKeys({
|
||||
search: searchText,
|
||||
...paginationParams,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(ingestionKeys?.data.data?.keys || []);
|
||||
setTotalIngestionKeys(ingestionKeys?.data?.data?._pagination?.total || 0);
|
||||
setActiveAPIKey(IngestionKeys?.data.data[0]);
|
||||
}, [IngestionKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(IngestionKeys?.data.data || []);
|
||||
setTotalIngestionKeys(IngestionKeys?.data?._pagination?.total || 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ingestionKeys?.data?.data]);
|
||||
}, [IngestionKeys?.data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
@@ -357,7 +297,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -370,54 +309,101 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const {
|
||||
mutate: createIngestionKey,
|
||||
isLoading: isLoadingCreateAPIKey,
|
||||
} = useCreateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
} = useMutation(createIngestionKeyApi, {
|
||||
onSuccess: (data) => {
|
||||
setActiveAPIKey(data.payload);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updateAPIKey,
|
||||
isLoading: isLoadingUpdateAPIKey,
|
||||
} = useUpdateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
|
||||
updateIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: deleteAPIKey,
|
||||
isLoading: isDeleteingAPIKey,
|
||||
} = useDeleteIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
|
||||
deleteIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: createLimitForIngestionKey,
|
||||
isLoading: isLoadingLimitForKey,
|
||||
} = useCreateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
} = useMutation(createLimitForIngestionKeyApi, {
|
||||
onSuccess: () => {
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
setHasCreateLimitForIngestionKeyError(true);
|
||||
setCreateLimitForIngestionKeyError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updateLimitForIngestionKey,
|
||||
isLoading: isLoadingUpdatedLimitForKey,
|
||||
} = useUpdateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
} = useMutation(updateLimitForIngestionKeyApi, {
|
||||
onSuccess: () => {
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
setHasUpdateLimitForIngestionKeyError(true);
|
||||
setUpdateLimitForIngestionKeyError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: deleteLimitForKey,
|
||||
isLoading: isDeletingLimit,
|
||||
} = useDeleteIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
const { mutate: deleteLimitForKey, isLoading: isDeletingLimit } = useMutation(
|
||||
deleteLimitsForIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsDeleteLimitModalOpen(false);
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onDeleteHandler = (): void => {
|
||||
clearSearch();
|
||||
|
||||
if (activeAPIKey && activeAPIKey.id) {
|
||||
deleteAPIKey(
|
||||
{
|
||||
pathParams: { keyId: activeAPIKey.id },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key deleted successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (activeAPIKey) {
|
||||
deleteAPIKey(activeAPIKey.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -425,31 +411,15 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (activeAPIKey && activeAPIKey.id) {
|
||||
updateAPIKey(
|
||||
{
|
||||
pathParams: { keyId: activeAPIKey.id },
|
||||
data: {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: new Date(
|
||||
dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
),
|
||||
},
|
||||
if (activeAPIKey) {
|
||||
updateAPIKey({
|
||||
id: activeAPIKey.id,
|
||||
data: {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key updated successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
@@ -465,30 +435,10 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const requestPayload = {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
};
|
||||
|
||||
createIngestionKey(
|
||||
{
|
||||
data: requestPayload,
|
||||
},
|
||||
{
|
||||
onSuccess: (_data) => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key created successfully',
|
||||
});
|
||||
// The new API returns GatewaytypesGettableCreatedIngestionKeyDTO with only id and value
|
||||
// We rely on refetchAPIKeys to get the full key object
|
||||
setActiveAPIKey(null);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
createIngestionKey(requestPayload);
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
@@ -515,7 +465,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.UTC_MONTH_COMPACT);
|
||||
|
||||
const showDeleteLimitModal = (
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
APIKey: IngestionKeyProps,
|
||||
limit: LimitProps,
|
||||
): void => {
|
||||
setActiveAPIKey(APIKey);
|
||||
@@ -539,17 +489,9 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
const handleAddLimit = (
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
APIKey: IngestionKeyProps,
|
||||
signalName: string,
|
||||
): void => {
|
||||
if (!APIKey.id) {
|
||||
notifications.error({
|
||||
message: 'Invalid ingestion key',
|
||||
description: 'Cannot create limit for ingestion key without a valid ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
dailyLimit,
|
||||
secondsLimit,
|
||||
@@ -634,49 +576,13 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
createLimitForIngestionKey(
|
||||
{
|
||||
pathParams: { keyId: payload.keyID },
|
||||
data: {
|
||||
signal: payload.signal,
|
||||
config: payload.config,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit created successfully',
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setHasCreateLimitForIngestionKeyError(true);
|
||||
setCreateLimitForIngestionKeyError(
|
||||
error.response?.data?.error?.message || 'Failed to create limit',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
createLimitForIngestionKey(payload);
|
||||
};
|
||||
|
||||
const handleUpdateLimit = (
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
if (!signal.id) {
|
||||
notifications.error({
|
||||
message: 'Invalid limit',
|
||||
description: 'Cannot update limit without a valid ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
dailyLimit,
|
||||
secondsLimit,
|
||||
@@ -738,34 +644,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
updateLimitForIngestionKey(
|
||||
{
|
||||
pathParams: { limitId: payload.limitID },
|
||||
data: {
|
||||
config: payload.config,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit updated successfully',
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setHasUpdateLimitForIngestionKeyError(true);
|
||||
setUpdateLimitForIngestionKeyError(
|
||||
error.response?.data?.error?.message || 'Failed to update limit',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
updateLimitForIngestionKey(payload);
|
||||
};
|
||||
/* eslint-enable sonarjs/cognitive-complexity */
|
||||
|
||||
@@ -777,7 +656,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
const enableEditLimitMode = (
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
const dayCount = signal?.config?.day?.count;
|
||||
@@ -786,11 +665,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const dayCountConverted = countToUnit(dayCount || 0);
|
||||
const secondCountConverted = countToUnit(secondCount || 0);
|
||||
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
|
||||
setActiveAPIKey(APIKey);
|
||||
setActiveSignal({
|
||||
...signal,
|
||||
@@ -829,31 +703,14 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const onDeleteLimitHandler = (): void => {
|
||||
if (activeSignal && activeSignal.id) {
|
||||
deleteLimitForKey(
|
||||
{
|
||||
pathParams: { limitId: activeSignal.id },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit deleted successfully',
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsDeleteLimitModalOpen(false);
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
deleteLimitForKey(activeSignal.id);
|
||||
}
|
||||
};
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleCreateAlert = (
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
let metricName = '';
|
||||
@@ -914,61 +771,31 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
|
||||
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'Ingestion Key',
|
||||
key: 'ingestion-key',
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
render: (APIKey: GatewaytypesIngestionKeyDTO): JSX.Element => {
|
||||
const createdOn = APIKey?.created_at
|
||||
? getFormattedTime(
|
||||
dayjs(APIKey.created_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)
|
||||
: '';
|
||||
render: (APIKey: IngestionKeyProps): JSX.Element => {
|
||||
const createdOn = getFormattedTime(
|
||||
APIKey.created_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
const expiresOn =
|
||||
!APIKey?.expires_at ||
|
||||
dayjs(APIKey?.expires_at).toISOString() === '0001-01-01T00:00:00.000Z'
|
||||
!APIKey?.expires_at || APIKey?.expires_at === '0001-01-01T00:00:00Z'
|
||||
? 'No Expiry'
|
||||
: getFormattedTime(
|
||||
dayjs(APIKey?.expires_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
: getFormattedTime(APIKey?.expires_at, formatTimezoneAdjustedTimestamp);
|
||||
|
||||
const updatedOn = APIKey?.updated_at
|
||||
? getFormattedTime(
|
||||
dayjs(APIKey.updated_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)
|
||||
: '';
|
||||
|
||||
const onCopyKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (APIKey?.value) {
|
||||
handleCopyKey(APIKey.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onEditKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
};
|
||||
|
||||
const onDeleteKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
};
|
||||
const updatedOn = getFormattedTime(
|
||||
APIKey?.updated_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
// Convert array of limits to a dictionary for quick access
|
||||
const limitsDict: Record<string, LimitProps> = {};
|
||||
APIKey.limits?.forEach((limitItem) => {
|
||||
if (limitItem.signal && limitItem.id) {
|
||||
limitsDict[limitItem.signal] = limitItem as LimitProps;
|
||||
}
|
||||
APIKey.limits?.forEach((limitItem: LimitProps) => {
|
||||
limitsDict[limitItem.signal] = limitItem;
|
||||
});
|
||||
|
||||
const hasLimits = (signalName: string): boolean => !!limitsDict[signalName];
|
||||
@@ -985,25 +812,39 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
<div className="ingestion-key-value">
|
||||
<Typography.Text>
|
||||
{APIKey?.value?.substring(0, 2)}********
|
||||
{APIKey?.value
|
||||
?.substring(APIKey?.value?.length ? APIKey.value.length - 2 : 0)
|
||||
?.trim()}
|
||||
{APIKey?.value.substring(0, 2)}********
|
||||
{APIKey?.value.substring(APIKey.value.length - 2).trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy className="copy-key-btn" size={12} onClick={onCopyKey} />
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyKey(APIKey.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={onEditKey}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
onClick={onDeleteKey}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,7 +854,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
<Row>
|
||||
<Col span={6}> ID </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{APIKey?.id}</Typography.Text>
|
||||
<Typography.Text>{APIKey.id}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -1065,39 +906,6 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
limit?.config?.second?.size !== undefined ||
|
||||
limit?.config?.second?.count !== undefined;
|
||||
|
||||
const onEditSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limit);
|
||||
};
|
||||
|
||||
const onDeleteSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limit);
|
||||
};
|
||||
|
||||
const onAddSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signalName,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveSignalLimit = (): void => {
|
||||
if (!hasLimits(signalName)) {
|
||||
handleAddLimit(APIKey, signalName);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limitsDict[signalName]);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateSignalAlert = (): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName]);
|
||||
|
||||
return (
|
||||
<div className="signal" key={signalName}>
|
||||
<div className="header">
|
||||
@@ -1108,18 +916,22 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
onClick={onEditSignalLimit}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limit);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
onClick={onDeleteSignalLimit}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limit);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1128,8 +940,16 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<PlusIcon size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
|
||||
onClick={onAddSignalLimit}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signalName,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Limits
|
||||
</Button>
|
||||
@@ -1138,7 +958,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="signal-limit-values">
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal?.signal === signalName &&
|
||||
isEditAddLimitOpen ? (
|
||||
<Form
|
||||
@@ -1334,27 +1154,27 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasCreateLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError && (
|
||||
createLimitForIngestionKeyError?.error && (
|
||||
<div className="error">
|
||||
{createLimitForIngestionKeyError}
|
||||
{createLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasUpdateLimitForIngestionKeyError &&
|
||||
updateLimitForIngestionKeyError && (
|
||||
updateLimitForIngestionKeyError?.error && (
|
||||
<div className="error">
|
||||
{updateLimitForIngestionKeyError}
|
||||
{updateLimitForIngestionKeyError?.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
isEditAddLimitOpen && (
|
||||
<div className="signal-limit-save-discard">
|
||||
@@ -1368,7 +1188,13 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={onSaveSignalLimit}
|
||||
onClick={(): void => {
|
||||
if (!hasLimits(signalName)) {
|
||||
handleAddLimit(APIKey, signalName);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limitsDict[signalName]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -1449,7 +1275,9 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
className="set-alert-btn periscope-btn ghost"
|
||||
type="text"
|
||||
data-testid={`set-alert-btn-${signalName}`}
|
||||
onClick={onCreateSignalAlert}
|
||||
onClick={(): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName])
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1564,7 +1392,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const handleTableChange = (pagination: TablePaginationConfig): void => {
|
||||
setPaginationParams({
|
||||
page: pagination?.current || 1,
|
||||
per_page: INITIAL_PAGE_SIZE,
|
||||
per_page: 10,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1662,7 +1490,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
showHeader={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
pageSize: isSearching ? SEARCH_PAGE_SIZE : paginationParams?.per_page,
|
||||
pageSize: paginationParams?.per_page,
|
||||
hideOnSinglePage: true,
|
||||
showTotal: (total: number, range: number[]): string =>
|
||||
`${range[0]}-${range[1]} of ${total} Ingestion keys`,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -19,12 +18,6 @@ interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
|
||||
data: TestIngestionKeyProps[];
|
||||
}
|
||||
|
||||
// Gateway API response type (uses actual schema types for contract safety)
|
||||
interface TestGatewayIngestionKeysResponse {
|
||||
status: string;
|
||||
data: GatewaytypesGettableIngestionKeysDTO;
|
||||
}
|
||||
|
||||
// Mock useHistory.push to capture navigation URL used by MultiIngestionSettings
|
||||
const mockPush = jest.fn() as jest.MockedFunction<(path: string) => void>;
|
||||
jest.mock('react-router-dom', () => {
|
||||
@@ -93,34 +86,32 @@ describe('MultiIngestionSettings Page', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a metrics daily count limit so the alert button is visible
|
||||
const response: TestGatewayIngestionKeysResponse = {
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
data: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
@@ -266,95 +257,4 @@ describe('MultiIngestionSettings Page', () => {
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
});
|
||||
|
||||
it('switches to search API when search text is entered', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const getResponse: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Regular',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret1',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const searchResponse: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Search Result',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret2',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const getHandler = jest.fn();
|
||||
const searchHandler = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v2/gateway/ingestion_keys', (req, res, ctx) => {
|
||||
if (req.url.pathname.endsWith('/search')) {
|
||||
return undefined;
|
||||
}
|
||||
getHandler();
|
||||
return res(ctx.status(200), ctx.json(getResponse));
|
||||
}),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys/search', (_req, res, ctx) => {
|
||||
searchHandler();
|
||||
return res(ctx.status(200), ctx.json(searchResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
await screen.findByText('Key Regular');
|
||||
expect(getHandler).toHaveBeenCalled();
|
||||
expect(searchHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Reset getHandler count to verify it's not called again during search
|
||||
getHandler.mockClear();
|
||||
|
||||
// Type in search box
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Search for ingestion key...',
|
||||
);
|
||||
await user.type(searchInput, 'test');
|
||||
|
||||
await screen.findByText('Key Search Result');
|
||||
expect(searchHandler).toHaveBeenCalled();
|
||||
expect(getHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Clear search
|
||||
searchHandler.mockClear();
|
||||
getHandler.mockClear();
|
||||
await user.clear(searchInput);
|
||||
|
||||
await screen.findByText('Key Regular');
|
||||
// Search API should be disabled when not searching
|
||||
expect(searchHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0px;
|
||||
.more-filter-actions {
|
||||
.group-by-clause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.more-filter-actions:hover {
|
||||
.group-by-clause:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
.more-filter-actions {
|
||||
.group-by-clause {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
@@ -17,12 +16,7 @@ import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Ellipsis,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
@@ -211,70 +205,6 @@ export default function TableViewActions(
|
||||
viewName,
|
||||
]);
|
||||
|
||||
const handleReplaceFilter = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only replace filters for index 0
|
||||
if (index === 0) {
|
||||
const newFilterItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
// Create new filter items array with single IN filter
|
||||
const newFilters = {
|
||||
items: [
|
||||
{
|
||||
id: '',
|
||||
key: newFilterItem,
|
||||
op: OPERATORS.IN,
|
||||
value: [parseFieldValue(fieldData.value)],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Clear the expression and update filters
|
||||
return {
|
||||
...item,
|
||||
filters: newFilters,
|
||||
filter: { expression: '' },
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
fieldData,
|
||||
handleChangeSelectedView,
|
||||
viewName,
|
||||
]);
|
||||
|
||||
// Memoize textToCopy computation
|
||||
const textToCopy = useMemo(() => {
|
||||
let text = fieldData.value;
|
||||
@@ -397,21 +327,13 @@ export default function TableViewActions(
|
||||
content={
|
||||
<div>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
className="group-by-clause"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
@@ -483,21 +405,13 @@ export default function TableViewActions(
|
||||
content={
|
||||
<div>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
className="group-by-clause"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
|
||||
@@ -407,10 +407,6 @@
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-500) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import { Form, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import getVersion from 'api/v1/version/get';
|
||||
import get from 'api/v2/sessions/context/get';
|
||||
import post from 'api/v2/sessions/email_password/post';
|
||||
@@ -220,20 +220,6 @@ function Login(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPasswordClick = useCallback((): void => {
|
||||
const email = form.getFieldValue('email');
|
||||
|
||||
if (!email || !sessionsContext || !sessionsContext?.orgs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(ROUTES.FORGOT_PASSWORD, {
|
||||
email,
|
||||
orgId: sessionsOrgId,
|
||||
orgs: sessionsContext.orgs,
|
||||
});
|
||||
}, [form, sessionsContext, sessionsOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackAuthError) {
|
||||
setErrorMessage(
|
||||
@@ -359,16 +345,11 @@ function Login(): JSX.Element {
|
||||
<ParentContainer>
|
||||
<div className="password-label-container">
|
||||
<Label htmlFor="Password">Password</Label>
|
||||
<Typography.Link
|
||||
className="forgot-password-link"
|
||||
href="#"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
handleForgotPasswordClick();
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</Typography.Link>
|
||||
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
|
||||
<Typography.Link className="forgot-password-link">
|
||||
Forgot password?
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<FormContainer.Item name="password">
|
||||
<Input.Password
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
@@ -9,24 +10,22 @@ import {
|
||||
Popover,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import { Filter } from 'api/v5/v5';
|
||||
import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Search } from 'lucide-react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SUMMARY_FILTERS_KEY } from './constants';
|
||||
|
||||
function MetricNameSearch({
|
||||
queryFilterExpression,
|
||||
onFilterChange,
|
||||
queryFilters,
|
||||
}: {
|
||||
queryFilterExpression: Filter;
|
||||
onFilterChange: (value: string) => void;
|
||||
queryFilters: TagFilter;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
|
||||
@@ -68,12 +67,9 @@ function MetricNameSearch({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricName: string): void => {
|
||||
const queryFilters = convertExpressionToFilters(
|
||||
queryFilterExpression?.expression,
|
||||
);
|
||||
const newFilters = {
|
||||
items: [
|
||||
...queryFilters,
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'metric_name',
|
||||
op: 'CONTAINS',
|
||||
@@ -87,11 +83,13 @@ function MetricNameSearch({
|
||||
],
|
||||
op: 'and',
|
||||
};
|
||||
const newFilterExpression = convertFiltersToExpression(newFilters);
|
||||
onFilterChange(newFilterExpression.expression);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[queryFilterExpression, onFilterChange],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const metricNameFilterValues = useMemo(
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Button, Menu, Popover, Tooltip } from 'antd';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { Search } from 'lucide-react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP_V2 } from './constants';
|
||||
import {
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
SUMMARY_FILTERS_KEY,
|
||||
} from './constants';
|
||||
|
||||
function MetricTypeSearch({
|
||||
queryFilters,
|
||||
onFilterChange,
|
||||
}: {
|
||||
queryFilters: TagFilter;
|
||||
onFilterChange: (expression: string) => void;
|
||||
}): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const menuItems = useMemo(
|
||||
@@ -22,9 +26,9 @@ function MetricTypeSearch({
|
||||
key: 'all',
|
||||
value: 'All',
|
||||
},
|
||||
...Object.keys(METRIC_TYPE_LABEL_MAP_V2).map((key) => ({
|
||||
key: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
|
||||
value: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
|
||||
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
|
||||
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
|
||||
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
})),
|
||||
],
|
||||
[],
|
||||
@@ -32,17 +36,16 @@ function MetricTypeSearch({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricType: string): void => {
|
||||
let newFilters;
|
||||
if (selectedMetricType !== 'all') {
|
||||
newFilters = {
|
||||
const newFilters = {
|
||||
items: [
|
||||
...queryFilters.items,
|
||||
{
|
||||
id: 'ttype',
|
||||
id: 'metric_type',
|
||||
op: '=',
|
||||
key: {
|
||||
id: 'type',
|
||||
key: 'type',
|
||||
id: 'metric_type',
|
||||
key: 'metric_type',
|
||||
type: 'tag',
|
||||
},
|
||||
value: selectedMetricType,
|
||||
@@ -50,17 +53,23 @@ function MetricTypeSearch({
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
} else {
|
||||
newFilters = {
|
||||
items: queryFilters.items.filter((item) => item.id !== 'type'),
|
||||
const newFilters = {
|
||||
items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
|
||||
op: 'AND',
|
||||
};
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
|
||||
});
|
||||
}
|
||||
const newFilterExpression = convertFiltersToExpression(newFilters);
|
||||
onFilterChange(newFilterExpression.expression);
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[queryFilters.items, onFilterChange],
|
||||
[queryFilters.items, setSearchParams, searchParams],
|
||||
);
|
||||
|
||||
const menu = (
|
||||
|
||||
@@ -1,59 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { Tooltip } from 'antd';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { Info, Play } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { HardHat, Info } from 'lucide-react';
|
||||
|
||||
import { MetricsSearchProps } from './types';
|
||||
|
||||
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
|
||||
const [currentExpression, setCurrentExpression] = useState<string>(
|
||||
query?.filter?.expression || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const expression = query?.filter?.expression || '';
|
||||
setCurrentExpression(expression);
|
||||
}, [query?.filter?.expression]);
|
||||
|
||||
const handleOnChange = (expression: string): void => {
|
||||
setCurrentExpression(expression);
|
||||
};
|
||||
|
||||
const handleStageAndRunQuery = (): void => onChange(currentExpression);
|
||||
|
||||
return (
|
||||
<div className="metrics-search-container">
|
||||
<div data-testid="qb-search-container" className="qb-search-container">
|
||||
<div className="qb-search-container">
|
||||
<Tooltip
|
||||
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
<QuerySearch
|
||||
onChange={handleOnChange}
|
||||
dataSource={DataSource.METRICS}
|
||||
queryData={{
|
||||
...query,
|
||||
filter: {
|
||||
...query?.filter,
|
||||
expression: currentExpression,
|
||||
},
|
||||
}}
|
||||
onRun={handleOnChange}
|
||||
showFilterSuggestionsWithoutMetric
|
||||
<QueryBuilderSearch
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
suffixIcon={<HardHat size={16} />}
|
||||
isMetricsExplorer
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleStageAndRunQuery}
|
||||
className="stage-run-query"
|
||||
icon={<Play size={14} />}
|
||||
>
|
||||
Stage & Run Query
|
||||
</Button>
|
||||
<div className="metrics-search-options">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
|
||||
@@ -24,8 +24,7 @@ function MetricsTable({
|
||||
setOrderBy,
|
||||
totalCount,
|
||||
openMetricDetails,
|
||||
queryFilterExpression,
|
||||
onFilterChange,
|
||||
queryFilters,
|
||||
}: MetricsTableProps): JSX.Element {
|
||||
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
|
||||
(
|
||||
@@ -37,17 +36,13 @@ function MetricsTable({
|
||||
): void => {
|
||||
if ('field' in sorter && sorter.order) {
|
||||
setOrderBy({
|
||||
key: {
|
||||
name: sorter.field as string,
|
||||
},
|
||||
direction: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
columnName: sorter.field as string,
|
||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
} else {
|
||||
setOrderBy({
|
||||
key: {
|
||||
name: 'samples',
|
||||
},
|
||||
direction: 'desc',
|
||||
columnName: 'samples',
|
||||
order: 'desc',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -56,17 +51,19 @@ function MetricsTable({
|
||||
|
||||
return (
|
||||
<div className="metrics-table-container">
|
||||
<div className="metrics-table-title" data-testid="metrics-table-title">
|
||||
<Typography.Title level={4} className="metrics-table-title">
|
||||
List View
|
||||
</Typography.Title>
|
||||
<Tooltip
|
||||
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isError && !isLoading && (
|
||||
<div className="metrics-table-title" data-testid="metrics-table-title">
|
||||
<Typography.Title level={4} className="metrics-table-title">
|
||||
List View
|
||||
</Typography.Title>
|
||||
<Tooltip
|
||||
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
loading={{
|
||||
spinning: isLoading,
|
||||
@@ -78,7 +75,7 @@ function MetricsTable({
|
||||
),
|
||||
}}
|
||||
dataSource={data}
|
||||
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
|
||||
columns={getMetricsTableColumns(queryFilters)}
|
||||
locale={{
|
||||
emptyText: isLoading ? null : (
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useWindowSize } from 'react-use';
|
||||
import { Group } from '@visx/group';
|
||||
import { Treemap } from '@visx/hierarchy';
|
||||
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { stratify, treemapBinary } from 'd3-hierarchy';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
TREEMAP_SQUARE_PADDING,
|
||||
TREEMAP_VIEW_OPTIONS,
|
||||
} from './constants';
|
||||
import { MetricsTreemapProps, TreemapTile } from './types';
|
||||
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
|
||||
import {
|
||||
getTreemapTileStyle,
|
||||
getTreemapTileTextStyle,
|
||||
@@ -41,9 +40,9 @@ function MetricsTreemap({
|
||||
|
||||
const treemapData = useMemo(() => {
|
||||
const extracedTreemapData =
|
||||
(viewType === MetricsexplorertypesTreemapModeDTO.timeseries
|
||||
? data?.timeseries
|
||||
: data?.samples) || [];
|
||||
(viewType === TreemapViewType.TIMESERIES
|
||||
? data?.data?.[TreemapViewType.TIMESERIES]
|
||||
: data?.data?.[TreemapViewType.SAMPLES]) || [];
|
||||
return transformTreemapData(extracedTreemapData, viewType);
|
||||
}, [data, viewType]);
|
||||
|
||||
@@ -55,36 +54,62 @@ function MetricsTreemap({
|
||||
const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT;
|
||||
const yMax = TREEMAP_HEIGHT - TREEMAP_MARGINS.TOP - TREEMAP_MARGINS.BOTTOM;
|
||||
|
||||
const treemapContent = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid="metrics-treemap-loading-state">
|
||||
<Skeleton style={{ width: treemapWidth, height: TREEMAP_HEIGHT }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Empty
|
||||
description="Error fetching metrics. If the problem persists, please contact support."
|
||||
data-testid="metrics-treemap-error-state"
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data?.[viewType]?.length) {
|
||||
return (
|
||||
<Empty
|
||||
description="No metrics found"
|
||||
data-testid="metrics-treemap-empty-state"
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid="metrics-treemap-loading-state">
|
||||
<Skeleton
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.data ||
|
||||
(data?.status === 'success' && !data?.data?.[viewType])
|
||||
) {
|
||||
return (
|
||||
<Empty
|
||||
description="No metrics found"
|
||||
data-testid="metrics-treemap-empty-state"
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.status === 'error' || isError) {
|
||||
return (
|
||||
<Empty
|
||||
description="Error fetching metrics. If the problem persists, please contact support."
|
||||
data-testid="metrics-treemap-error-state"
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="metrics-treemap-container"
|
||||
data-testid="metrics-treemap-container"
|
||||
>
|
||||
<div className="metrics-treemap-title">
|
||||
<div className="metrics-treemap-title-left">
|
||||
<Typography.Title level={4}>Proportion View</Typography.Title>
|
||||
<Tooltip
|
||||
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
options={TREEMAP_VIEW_OPTIONS}
|
||||
value={viewType}
|
||||
onChange={setHeatmapView}
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
width={treemapWidth}
|
||||
height={TREEMAP_HEIGHT}
|
||||
@@ -149,42 +174,6 @@ function MetricsTreemap({
|
||||
)}
|
||||
</Treemap>
|
||||
</svg>
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
isError,
|
||||
isLoading,
|
||||
openMetricDetails,
|
||||
transformedTreemapData,
|
||||
treemapWidth,
|
||||
viewType,
|
||||
xMax,
|
||||
yMax,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="metrics-treemap-container"
|
||||
data-testid="metrics-treemap-container"
|
||||
>
|
||||
<div className="metrics-treemap-title">
|
||||
<div className="metrics-treemap-title-left">
|
||||
<Typography.Title level={4}>Proportion View</Typography.Title>
|
||||
<Tooltip
|
||||
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
options={TREEMAP_VIEW_OPTIONS}
|
||||
value={viewType}
|
||||
onChange={setHeatmapView}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{treemapContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user