Compare commits
30 Commits
chore/butt
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fadd9fb08 | ||
|
|
0748565583 | ||
|
|
dd629a0e97 | ||
|
|
c76f796e39 | ||
|
|
73f2a785d3 | ||
|
|
4c473d3ce2 | ||
|
|
54705e73f2 | ||
|
|
f1d7f727fe | ||
|
|
f963a98953 | ||
|
|
029f7196b2 | ||
|
|
9976f7c95f | ||
|
|
37133429a2 | ||
|
|
f8479e33ba | ||
|
|
bb06867cd7 | ||
|
|
4da5673e12 | ||
|
|
c3db819d8e | ||
|
|
c83578f211 | ||
|
|
04a4d3fe32 | ||
|
|
27dc996fd8 | ||
|
|
83b25f3e9a | ||
|
|
67e4c4611c | ||
|
|
7274421895 | ||
|
|
9c6656d6b9 | ||
|
|
5c54a2537c | ||
|
|
bf201710a7 | ||
|
|
a5adc52276 | ||
|
|
5ddcf33811 | ||
|
|
c0fe996e7a | ||
|
|
1b6bb78ca4 | ||
|
|
0583f30e35 |
7
.github/CODEOWNERS
vendored
@@ -118,6 +118,9 @@ go.mod @therealpandey
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
|
||||
# Flagger Owners
|
||||
|
||||
/pkg/flagger/ @therealpandey
|
||||
@@ -162,3 +165,7 @@ go.mod @therealpandey
|
||||
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -2689,7 +2689,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -2759,7 +2758,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -2829,7 +2827,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -2908,7 +2905,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -2984,7 +2980,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -3032,7 +3027,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -3110,7 +3104,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -3209,7 +3202,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -3554,7 +3546,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
@@ -3615,7 +3606,6 @@ components:
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.19",
|
||||
"@signozhq/ui": "0.0.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
*/
|
||||
|
||||
const BANNED_COMPONENTS = {
|
||||
Typography: 'Use @signozhq/ui Typography instead of antd Typography.',
|
||||
Typography:
|
||||
'Use @signozhq/ui/typography Typography instead of antd Typography.',
|
||||
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
31
frontend/pnpm-lock.yaml
generated
@@ -77,8 +77,8 @@ importers:
|
||||
specifier: 0.0.2
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.19
|
||||
version: 0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
specifier: 0.0.21
|
||||
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -3269,8 +3269,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
'@signozhq/ui@0.0.19':
|
||||
resolution: {integrity: sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==}
|
||||
'@signozhq/ui@0.0.21':
|
||||
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -3851,27 +3851,6 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
|
||||
'@webassemblyjs/floating-point-hex-parser@1.13.2':
|
||||
resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==}
|
||||
|
||||
'@webassemblyjs/helper-api-error@1.13.2':
|
||||
resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==}
|
||||
|
||||
'@webassemblyjs/helper-buffer@1.14.1':
|
||||
resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==}
|
||||
|
||||
'@webassemblyjs/helper-numbers@1.13.2':
|
||||
resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==}
|
||||
|
||||
'@webassemblyjs/helper-wasm-bytecode@1.13.2':
|
||||
resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==}
|
||||
|
||||
'@webassemblyjs/helper-wasm-section@1.14.1':
|
||||
resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==}
|
||||
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -12034,7 +12013,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -166,6 +166,7 @@ function createMockAppContext(
|
||||
userPreferences: [],
|
||||
hostsData: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -59,6 +59,7 @@ function App(): JSX.Element {
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
isPreflightLoading,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
@@ -386,6 +387,10 @@ function App(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCloudUser, isEnterpriseSelfHostedUser]);
|
||||
|
||||
if (isPreflightLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
// if the user is in logged in state
|
||||
if (isLoggedInState) {
|
||||
// if the setup calls are loading then return a spinner
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { interceptorRejected } from '../index';
|
||||
|
||||
jest.mock('utils/noAuthMode', () => ({
|
||||
getIsNoAuthMode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v2/sessions/rotate/post', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const post = require('api/v2/sessions/rotate/post').default;
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const { Logout } = require('../utils');
|
||||
|
||||
describe('interceptorRejected — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
|
||||
(post as jest.Mock).mockResolvedValue({
|
||||
data: { accessToken: 'a', refreshToken: 'b' },
|
||||
});
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3488,9 +3488,9 @@ export interface InframonitoringtypesClustersDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesClusterRecordDTO[] | null;
|
||||
records: InframonitoringtypesClusterRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3566,9 +3566,9 @@ export interface InframonitoringtypesDaemonSetsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDaemonSetRecordDTO[] | null;
|
||||
records: InframonitoringtypesDaemonSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3644,9 +3644,9 @@ export interface InframonitoringtypesDeploymentsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesDeploymentRecordDTO[] | null;
|
||||
records: InframonitoringtypesDeploymentRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3730,9 +3730,9 @@ export interface InframonitoringtypesHostsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesHostRecordDTO[] | null;
|
||||
records: InframonitoringtypesHostRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3816,9 +3816,9 @@ export interface InframonitoringtypesJobsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesJobRecordDTO[] | null;
|
||||
records: InframonitoringtypesJobRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3866,9 +3866,9 @@ export interface InframonitoringtypesNamespacesDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNamespaceRecordDTO[] | null;
|
||||
records: InframonitoringtypesNamespaceRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3933,9 +3933,9 @@ export interface InframonitoringtypesNodesDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[] | null;
|
||||
records: InframonitoringtypesNodeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -4017,9 +4017,9 @@ export interface InframonitoringtypesPodsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesPodRecordDTO[] | null;
|
||||
records: InframonitoringtypesPodRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -4437,9 +4437,9 @@ export interface InframonitoringtypesStatefulSetsDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesStatefulSetRecordDTO[] | null;
|
||||
records: InframonitoringtypesStatefulSetRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
@@ -4506,9 +4506,9 @@ export interface InframonitoringtypesVolumesDTO {
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
records: InframonitoringtypesVolumeRecordDTO[] | null;
|
||||
records: InframonitoringtypesVolumeRecordDTO[];
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
import { Logout } from './utils';
|
||||
@@ -108,7 +109,10 @@ export const interceptorRejected = async (
|
||||
if (axios.isAxiosError(value) && value.response) {
|
||||
const { response } = value;
|
||||
|
||||
const isNoAuthMode = getIsNoAuthMode();
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
@@ -140,16 +144,20 @@ export const interceptorRejected = async (
|
||||
return await Promise.resolve(reResponse);
|
||||
} catch (error) {
|
||||
if ((error as AxiosError)?.response?.status === 401) {
|
||||
Logout();
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logout();
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401 && response.config.url === '/sessions/rotate') {
|
||||
Logout();
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
response.config.url === '/sessions/rotate'
|
||||
) {
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
return await Promise.reject(value);
|
||||
|
||||
3
frontend/src/assets/Logos/apache-druid.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
7
frontend/src/assets/Logos/aspnet.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="456" height="456" rx="50" fill="#512BD4"/>
|
||||
<path d="M81.2738 291.333C78.0496 291.333 75.309 290.259 73.052 288.11C70.795 285.906 69.6665 283.289 69.6665 280.259C69.6665 277.173 70.795 274.529 73.052 272.325C75.309 270.121 78.0496 269.019 81.2738 269.019C84.5518 269.019 87.3193 270.121 89.5763 272.325C91.887 274.529 93.0424 277.173 93.0424 280.259C93.0424 283.289 91.887 285.906 89.5763 288.11C87.3193 290.259 84.5518 291.333 81.2738 291.333Z" fill="white"/>
|
||||
<path d="M210.167 289.515H189.209L133.994 202.406C132.597 200.202 131.441 197.915 130.528 195.546H130.044C130.474 198.081 130.689 203.508 130.689 211.827V289.515H112.149V171H134.477L187.839 256.043C190.096 259.57 191.547 261.994 192.192 263.316H192.514C191.977 260.176 191.708 254.859 191.708 247.365V171H210.167V289.515Z" fill="white"/>
|
||||
<path d="M300.449 289.515H235.561V171H297.87V187.695H254.746V221.249H294.485V237.861H254.746V272.903H300.449V289.515Z" fill="white"/>
|
||||
<path d="M392.667 187.695H359.457V289.515H340.272V187.695H307.143V171H392.667V187.695Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
15
frontend/src/assets/Logos/azure-cdn-frontdoor.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
|
||||
<defs>
|
||||
<linearGradient id="a" x1="9" y1="17" x2="9" y2="1" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0078d4"/>
|
||||
<stop offset="1" stop-color="#5ea0ef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="9" cy="9" r="8" fill="url(#a)"/>
|
||||
<ellipse cx="9" cy="9" rx="3.2" ry="8" fill="none" stroke="#fff" stroke-width=".7"/>
|
||||
<line x1="1" y1="9" x2="17" y2="9" stroke="#fff" stroke-width=".7"/>
|
||||
<line x1="2" y1="5.5" x2="16" y2="5.5" stroke="#fff" stroke-width=".5"/>
|
||||
<line x1="2" y1="12.5" x2="16" y2="12.5" stroke="#fff" stroke-width=".5"/>
|
||||
<circle cx="9" cy="9" r="8" fill="none" stroke="#fff" stroke-width=".7"/>
|
||||
<path d="M13.5 10.5l1.5-1.5-1.5-1.5M4.5 10.5L3 9l1.5-1.5" stroke="#50e6ff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 877 B |
15
frontend/src/assets/Logos/cert-manager.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_0_812)" transform="matrix(1.002103,0,0,1.0377318,6.9399999e-7,-2.5317276e-4)">
|
||||
<path d="m 141.702,68.418 c 0,7.4632 -4.567,14.1123 -6.748,20.8385 -2.263,6.9789 -2.552,15.0285 -6.776,20.8385 -4.267,5.868 -11.856,8.611 -17.719,12.881 -5.805,4.228 -10.7345,10.628 -17.7061,12.895 -6.7286,2.186 -14.4463,-0.021 -21.9018,-0.021 -7.4555,0 -15.1731,2.207 -21.8998,0.021 C 41.9778,133.604 37.048,127.204 31.2428,122.976 25.3799,118.706 17.7913,115.963 13.5247,110.095 9.30055,104.287 9.01135,96.2374 6.74791,89.2565 4.56351,82.5225 0,75.8735 0,68.418 0,60.9624 4.56737,54.3057 6.74791,47.5795 9.01135,40.6005 9.30055,32.5507 13.5247,26.741 17.7913,20.8753 25.3799,18.1297 31.2428,13.8617 37.048,9.63414 41.9778,3.23209 48.9513,0.966872 55.678,-1.21924 63.3956,0.986167 70.8511,0.986167 c 7.4555,0 15.1732,-2.205407 21.8999,-0.019295 6.9735,2.265218 11.903,8.667268 17.708,12.894828 5.863,4.268 13.452,7.0136 17.719,12.8793 4.224,5.8097 4.513,13.8595 6.776,20.8385 2.181,6.7262 6.748,13.3771 6.748,20.8385 z" fill="#326ce5"/>
|
||||
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
|
||||
<path d="m 13.5883,60.53 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 13.5883,68.248 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 13.5883,77.5095 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
|
||||
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_812">
|
||||
<rect width="141.702" height="136.837" fill="#ffffff"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
55
frontend/src/assets/Logos/graphql.svg
Normal file
@@ -0,0 +1,55 @@
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/>
|
||||
</g>
|
||||
</g>
|
||||
<path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C373.5,259.9,379.2,281.2,369.5,297.9"/>
|
||||
<path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C94.8,99,100.5,120.3,90.9,137"/>
|
||||
<path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C61.4,320.3,40.1,314.6,30.5,297.9"/>
|
||||
<path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C340.1,159.4,318.7,153.7,309.1,137"/>
|
||||
<path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,380.1,219.3,395.8,200,395.8"/>
|
||||
<path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,58.4,219.3,74,200,74"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
frontend/src/assets/Logos/istio.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 77.62745 102.5">
|
||||
<path fill="#516baa" d="m31.05548,54.44523v24.1773c.00065.04512-.03164.084-.07611.09164l-23.27949,3.99047c-.05091.0076-.09834-.02751-.10594-.07841-.00256-.01712-.0003-.03461.00653-.05051L30.87996,30.58635c.02242-.04633.07815-.06572.12449-.04331.0316.01529.05193.04704.05259.08214l-.00156,23.82005Zm3.92367-13.93321v38.21148c.00046.04691.03573.08617.08232.09164l34.87031,3.89415c.0512.00527.09698-.03196.10226-.08316.00167-.01616-.00092-.03247-.00751-.04732L35.15623,4.70041c-.02237-.04636-.07809-.0658-.12444-.04343-.03117.01504-.05144.04612-.05264.08071v35.77433Zm34.68546,45.76213l-38.57341,11.57218c-.02155.00797-.04524.00797-.06679,0l-23.309-11.57217c-.04636-.0203-.06749-.07435-.04719-.12071.01513-.03455.04988-.0563.08757-.05481h61.88241c.0508.00825.08531.05613.07706.10693-.00482.0297-.02369.05525-.05066.06859Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 901 B |
3
frontend/src/assets/Logos/railway.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 776 B |
13
frontend/src/assets/Logos/scala.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 590 270">
|
||||
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M138.66,28.78C107.2,37.87,57.29,43,30.4,43h0V94.35a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.4a129,129,0,0,0,17.49-5.81c4.18-1.88,6.88-3.86,6.88-5.92V15.91C164.1,20.79,151.39,25.11,138.66,28.78Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,95.37c-18.83,5.43-44.24,9.47-67.39,11.83-15.54,1.59-30.06,2.42-40.87,2.42h0v51.31a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.39a130.38,130.38,0,0,0,17.49-5.81c4.18-1.89,6.88-3.86,6.88-5.92V82.5C164.1,87.37,151.39,91.69,138.66,95.37Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M138.66,162c-18.83,5.43-44.24,9.46-67.39,11.83-15.56,1.59-30.1,2.42-40.91,2.42V228c18.16,0,75.1-5.95,109.37-15.39,12.63-3.48,24.37-7.44,24.37-11.74V149.08C164.1,154,151.39,158.28,138.66,162Z" fill="#de3423" fill-rule="evenodd"/>
|
||||
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="currentColor" fill-rule="evenodd"/>
|
||||
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="currentColor"/>
|
||||
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="currentColor"/>
|
||||
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="currentColor"/>
|
||||
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="currentColor"/>
|
||||
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
37
frontend/src/assets/Logos/slog.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg viewBox="0 0 254.5 225" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M40.2,101.1c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l35.7,0c0.4,0,0.5,0.3,0.3,0.6 l-1.7,2.6c-0.2,0.3-0.7,0.6-1,0.6L40.2,101.1z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M25.1,110.3c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l45.6,0c0.4,0,0.6,0.3,0.5,0.6 l-0.8,2.4c-0.1,0.4-0.5,0.6-0.9,0.6L25.1,110.3z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M49.3,119.5c-0.4,0-0.5-0.3-0.3-0.6l1.4-2.5c0.2-0.3,0.6-0.6,1-0.6l20,0c0.4,0,0.6,0.3,0.6,0.7l-0.2,2.4 c0,0.4-0.4,0.7-0.7,0.7L49.3,119.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g id="CXHf1q_3_">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M153.1,99.3c-6.3,1.6-10.6,2.8-16.8,4.4c-1.5,0.4-1.6,0.5-2.9-1c-1.5-1.7-2.6-2.8-4.7-3.8 c-6.3-3.1-12.4-2.2-18.1,1.5c-6.8,4.4-10.3,10.9-10.2,19c0.1,8,5.6,14.6,13.5,15.7c6.8,0.9,12.5-1.5,17-6.6 c0.9-1.1,1.7-2.3,2.7-3.7c-3.6,0-8.1,0-19.3,0c-2.1,0-2.6-1.3-1.9-3c1.3-3.1,3.7-8.3,5.1-10.9c0.3-0.6,1-1.6,2.5-1.6 c5.1,0,23.9,0,36.4,0c-0.2,2.7-0.2,5.4-0.6,8.1c-1.1,7.2-3.8,13.8-8.2,19.6c-7.2,9.5-16.6,15.4-28.5,17 c-9.8,1.3-18.9-0.6-26.9-6.6c-7.4-5.6-11.6-13-12.7-22.2c-1.3-10.9,1.9-20.7,8.5-29.3c7.1-9.3,16.5-15.2,28-17.3 c9.4-1.7,18.4-0.6,26.5,4.9c5.3,3.5,9.1,8.3,11.6,14.1C154.7,98.5,154.3,99,153.1,99.3z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#00ACD7" d="M186.2,154.6c-9.1-0.2-17.4-2.8-24.4-8.8c-5.9-5.1-9.6-11.6-10.8-19.3c-1.8-11.3,1.3-21.3,8.1-30.2 c7.3-9.6,16.1-14.6,28-16.7c10.2-1.8,19.8-0.8,28.5,5.1c7.9,5.4,12.8,12.7,14.1,22.3c1.7,13.5-2.2,24.5-11.5,33.9 c-6.6,6.7-14.7,10.9-24,12.8C191.5,154.2,188.8,154.3,186.2,154.6z M210,114.2c-0.1-1.3-0.1-2.3-0.3-3.3 c-1.8-9.9-10.9-15.5-20.4-13.3c-9.3,2.1-15.3,8-17.5,17.4c-1.8,7.8,2,15.7,9.2,18.9c5.5,2.4,11,2.1,16.3-0.6 C205.2,129.2,209.5,122.8,210,114.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -51,13 +51,6 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
.ant-progress-bg {
|
||||
height: 8px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
Progress,
|
||||
Space,
|
||||
Spin,
|
||||
TableColumnsType,
|
||||
TableColumnType,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { FilterDropdownProps } from 'antd/lib/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -59,7 +59,7 @@ function ProgressRender(item: string | number): JSX.Element {
|
||||
<Progress
|
||||
percent={percent}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const cpuPercent = percent;
|
||||
if (cpuPercent >= 90) {
|
||||
|
||||
@@ -137,7 +137,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -11,9 +11,6 @@ import { GuardAuthZ } from './GuardAuthZ';
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const ErrorFallback = (error: Error): ReactElement => (
|
||||
<div>Error occurred: {error.message}</div>
|
||||
);
|
||||
const NoPermissionFallback = (_response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => <div>Access denied</div>;
|
||||
@@ -90,40 +87,28 @@ describe('GuardAuthZ', () => {
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs', async () => {
|
||||
const errorMessage = 'Internal Server Error';
|
||||
|
||||
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: errorMessage }));
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*" fallbackOnError={ErrorFallback}>
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error occurred:/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass error object to fallbackOnError function', async () => {
|
||||
const errorMessage = 'Network request failed';
|
||||
let receivedError: Error | null = null;
|
||||
|
||||
const errorFallbackWithCapture = (error: Error): ReactElement => {
|
||||
receivedError = error;
|
||||
return <div>Captured error: {error.message}</div>;
|
||||
};
|
||||
|
||||
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: errorMessage }));
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -131,35 +116,14 @@ describe('GuardAuthZ', () => {
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnError={errorFallbackWithCapture}
|
||||
fallbackOnError={<div>Custom error fallback</div>}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(receivedError).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(receivedError).toBeInstanceOf(Error);
|
||||
expect(screen.getByText(/Captured error:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when error occurs and no fallbackOnError provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
@@ -12,7 +12,7 @@ export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: (error: Error) => JSX.Element;
|
||||
fallbackOnError?: JSX.Element;
|
||||
fallbackOnNoPermissions?: (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}) => JSX.Element;
|
||||
@@ -35,7 +35,7 @@ export function GuardAuthZ<R extends AuthZRelation>({
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallbackOnError?.(error) ?? null;
|
||||
return fallbackOnError ?? children;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useSelector } from 'react-redux';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Switch } from 'antd';
|
||||
import { Button } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -125,9 +126,8 @@ function ShareURLModal(): JSX.Element {
|
||||
<Info size={14} color={Color.BG_AMBER_600} />
|
||||
)}
|
||||
<Switch
|
||||
checked={enableAbsoluteTime}
|
||||
value={enableAbsoluteTime}
|
||||
disabled={!isValidateRelativeTime}
|
||||
size="small"
|
||||
onChange={(): void => {
|
||||
setEnableAbsoluteTime((prev) => !prev);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.banner {
|
||||
height: var(--spacing-20);
|
||||
|
||||
a {
|
||||
color: var(--callout-warning-title);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--callout-warning-title);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/src/components/NoAuthBanner/NoAuthBanner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
|
||||
import styles from './NoAuthBanner.module.scss';
|
||||
|
||||
export function NoAuthBanner(): JSX.Element {
|
||||
return (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey="no-auth-banner-v1"
|
||||
testId="no-auth-banner"
|
||||
className={styles.banner}
|
||||
>
|
||||
Impersonation mode: authentication is disabled. Anyone with access to this
|
||||
instance has admin privileges.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/configuration/impersonation-mode/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</PersistedAnnouncementBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoAuthBanner;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { NoAuthBanner } from '../NoAuthBanner';
|
||||
|
||||
describe('NoAuthBanner', () => {
|
||||
it('renders the no-auth message', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(
|
||||
screen.getByText(/Impersonation mode: authentication is disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the warning test id', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a docs link that opens in a new tab', () => {
|
||||
render(<NoAuthBanner />);
|
||||
const link = screen.getByRole('link', { name: /learn more/i });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Switch, Tooltip } from 'antd';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
@@ -281,9 +282,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<div className="api-quick-filters-header">
|
||||
<Typography.Text>Show IP addresses</Typography.Text>
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={showIP ?? true}
|
||||
value={showIP ?? true}
|
||||
onChange={(checked): void => {
|
||||
logEvent('API Monitoring: Show IP addresses clicked', {
|
||||
showIP: checked,
|
||||
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps, Switch } from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -60,9 +61,7 @@ function DynamicColumnTable({
|
||||
|
||||
const onToggleHandler =
|
||||
(index: number, column: ColumnGroupType<any> | ColumnType<any>) =>
|
||||
(checked: boolean, event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
|
||||
(checked: boolean): void => {
|
||||
if (shouldSendAlertsLogEvent) {
|
||||
logEvent('Alert: Column toggled', {
|
||||
column: column?.title,
|
||||
@@ -88,10 +87,14 @@ function DynamicColumnTable({
|
||||
const items: MenuProps['items'] =
|
||||
dynamicColumns?.map((column, index) => ({
|
||||
label: (
|
||||
<div className="dynamicColumnsTable-items">
|
||||
<div
|
||||
className="dynamicColumnsTable-items"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div>{column.title?.toString()}</div>
|
||||
<Switch
|
||||
checked={columnsData?.findIndex((c) => c.key === column.key) !== -1}
|
||||
value={columnsData?.findIndex((c) => c.key === column.key) !== -1}
|
||||
onChange={onToggleHandler(index, column)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,6 @@ function KeyFormPhase({
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -190,7 +190,6 @@ function EditKeyForm({
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table } from 'antd';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
@@ -110,28 +110,34 @@ function buildColumns({
|
||||
onClick: (e): void => e.stopPropagation(),
|
||||
style: { cursor: 'default' },
|
||||
}),
|
||||
render: (_, record): JSX.Element => (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
render: (_, record): JSX.Element => {
|
||||
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('createGuardedRoute', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error fallback when API error occurs', async () => {
|
||||
it('should render the component when API error occurs (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
@@ -230,12 +230,8 @@ describe('createGuardedRoute', () => {
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render no permissions fallback when permission is denied', async () => {
|
||||
@@ -9,14 +9,11 @@ import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import AppLoading from '../AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
const onErrorFallback = (): JSX.Element => <ErrorBoundaryFallback />;
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
@@ -63,7 +60,6 @@ export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
relation={relation}
|
||||
object={resolvedObject}
|
||||
fallbackOnLoading={<AppLoading />}
|
||||
fallbackOnError={onErrorFallback}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
|
||||
@@ -11,4 +11,5 @@ export enum FeatureKeys {
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
DASHBOARD_V2 = 'dashboard_v2',
|
||||
}
|
||||
|
||||
@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
.contributors-row {
|
||||
height: 80px;
|
||||
}
|
||||
.top-contributors-progress {
|
||||
--progress-background: transparent;
|
||||
}
|
||||
|
||||
&__content {
|
||||
.ant-table {
|
||||
&-cell {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Table, TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Table, TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
@@ -51,8 +52,8 @@ function TopContributorsRows({
|
||||
<Progress
|
||||
percent={(count / totalCurrentTriggers) * 100}
|
||||
showInfo={false}
|
||||
trailColor="rgba(255, 255, 255, 0)"
|
||||
strokeColor={Color.BG_ROBIN_500}
|
||||
className="top-contributors-progress"
|
||||
/>
|
||||
</ConditionalAlertPopover>
|
||||
),
|
||||
|
||||
@@ -141,12 +141,9 @@
|
||||
|
||||
.progress-container {
|
||||
width: 158px;
|
||||
.ant-progress {
|
||||
margin: 0;
|
||||
|
||||
.ant-progress-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
span {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { Spin, Switch, Table, Tooltip } from 'antd';
|
||||
import { Spin, Table, Tooltip } from 'antd';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
@@ -170,11 +171,7 @@ function TopErrors({
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
checked={showStatusCodeErrors}
|
||||
onChange={setShowStatusCodeErrors}
|
||||
size="small"
|
||||
/>
|
||||
<Switch value={showStatusCodeErrors} onChange={setShowStatusCodeErrors} />
|
||||
<span style={{ color: 'white', fontSize: '14px' }}>
|
||||
Status Message Exists
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -136,12 +137,11 @@ function DomainMetrics({
|
||||
<Tooltip title={formattedDomainMetricsData.errorRate}>
|
||||
{formattedDomainMetricsData.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||
)}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
getDisplayValue,
|
||||
@@ -80,10 +81,9 @@ function EndPointMetrics({
|
||||
<Tooltip title={metricsData?.errorRate}>
|
||||
{metricsData?.errorRate !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
Number(metricsData?.errorRate ?? 0).toFixed(2),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
|
||||
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
@@ -257,10 +258,9 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
|
||||
errorRate === 'n/a' || errorRate === '-' ? 0 : errorRate;
|
||||
return (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number((errorRateValue as number).toFixed(2))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number((errorRateValue as number).toFixed(2));
|
||||
if (errorRatePercent >= 90) {
|
||||
@@ -1022,14 +1022,13 @@ export const getEndPointsColumnsConfig = (
|
||||
className: `column`,
|
||||
render: (errorRate: number | string): React.ReactNode => (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
(
|
||||
(errorRate === 'n/a' || errorRate === '-' ? 0 : errorRate) as number
|
||||
).toFixed(1),
|
||||
)}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number((errorRate as number).toFixed(1));
|
||||
if (errorRatePercent >= 90) {
|
||||
@@ -2514,10 +2513,9 @@ export const dependentServicesColumns: ColumnType<DependentServicesData>[] = [
|
||||
render: (errorPercentage: number | string): React.ReactNode =>
|
||||
errorPercentage !== '-' ? (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number((errorPercentage as number).toFixed(2))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorPercentagePercent = Number(
|
||||
(errorPercentage as number).toFixed(2),
|
||||
@@ -3022,14 +3020,13 @@ export const getAllEndpointsWidgetData = (
|
||||
),
|
||||
F1: (errorRate: any): ReactNode => (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(
|
||||
(
|
||||
(errorRate === 'n/a' || errorRate === '-' ? 0 : errorRate) as number
|
||||
).toFixed(2),
|
||||
)}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
showInfo
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(
|
||||
(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Flex, SelectProps, Switch } from 'antd';
|
||||
import { Button, Flex, SelectProps } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { BaseOptionType, DefaultOptionType } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
@@ -419,8 +420,8 @@ export function RoutingPolicyBanner({
|
||||
</Typography.Text>
|
||||
<div className="routing-policies-info-banner-right">
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
value={notificationSettings.routingPolicies}
|
||||
testId="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch, Tooltip } from 'antd';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Info } from '@signozhq/icons';
|
||||
|
||||
@@ -49,7 +50,7 @@ function AdvancedOptionItem({
|
||||
>
|
||||
{input}
|
||||
</div>
|
||||
<Switch onChange={handleOnToggle} checked={showInput} />
|
||||
<Switch onChange={handleOnToggle} value={showInput} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Collapse, Input, Select, Switch, Tag } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Tag } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import cx from 'classnames';
|
||||
@@ -763,7 +764,7 @@ function VariableItem({
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={variableMultiSelect}
|
||||
value={variableMultiSelect}
|
||||
onChange={(e): void => {
|
||||
setVariableMultiSelect(e);
|
||||
if (!e) {
|
||||
@@ -780,7 +781,7 @@ function VariableItem({
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={variableShowALLOption}
|
||||
value={variableShowALLOption}
|
||||
onChange={(e): void => setVariableShowALLOption(e)}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { usePanelContextMenu } from '../usePanelContextMenu';
|
||||
|
||||
// The hook composes `useCoordinates` (popover state) and `useGraphContextMenu`
|
||||
// (menu items). We mock both so the test focuses on the `enableDrillDown` gate
|
||||
// rather than the implementation of the menu wiring itself.
|
||||
const onClickMock = jest.fn();
|
||||
jest.mock('periscope/components/ContextMenu', () => ({
|
||||
useCoordinates: (): unknown => ({
|
||||
coordinates: null,
|
||||
popoverPosition: null,
|
||||
clickedData: null,
|
||||
onClose: jest.fn(),
|
||||
subMenu: null,
|
||||
onClick: onClickMock,
|
||||
setSubMenu: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryTable/Drilldown/useGraphContextMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): { menuItemsConfig: { header: string; items: string } } => ({
|
||||
menuItemsConfig: { header: 'menu-header', items: 'menu-items' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
|
||||
getUplotClickData: jest.fn(() => ({
|
||||
coord: { x: 1, y: 2 },
|
||||
record: { queryName: 'A', filters: [] },
|
||||
label: 'lbl',
|
||||
seriesColor: '#abc',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('container/PanelWrapper/utils', () => ({
|
||||
isApmMetric: jest.fn(() => false),
|
||||
getTimeRangeFromStepInterval: jest.fn(() => ({ start: 0, end: 0 })),
|
||||
}));
|
||||
|
||||
const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
|
||||
const mockQueryResponse = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
} as unknown as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
|
||||
describe('usePanelContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
onClickMock.mockClear();
|
||||
});
|
||||
|
||||
it('returns empty menuItemsConfig when enableDrillDown is false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelContextMenu({
|
||||
widget: mockWidget,
|
||||
queryResponse: mockQueryResponse,
|
||||
enableDrillDown: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.menuItemsConfig).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('returns wired menuItemsConfig when enableDrillDown is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelContextMenu({
|
||||
widget: mockWidget,
|
||||
queryResponse: mockQueryResponse,
|
||||
enableDrillDown: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.menuItemsConfig).toStrictEqual({
|
||||
header: 'menu-header',
|
||||
items: 'menu-items',
|
||||
});
|
||||
});
|
||||
|
||||
it('clickHandlerWithContextMenu is a no-op when enableDrillDown is false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelContextMenu({
|
||||
widget: mockWidget,
|
||||
queryResponse: mockQueryResponse,
|
||||
enableDrillDown: false,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.clickHandlerWithContextMenu(
|
||||
100, // xValue
|
||||
200, // yValue
|
||||
0, // mouseX
|
||||
0, // mouseY
|
||||
{ serviceName: 'svc' }, // metric
|
||||
{ queryName: 'A', inFocusOrNot: true }, // queryData
|
||||
10, // absoluteMouseX
|
||||
20, // absoluteMouseY
|
||||
{}, // axesData
|
||||
{ seriesIndex: 0, seriesName: 'A', value: 1, color: '#abc' }, // focusedSeries
|
||||
);
|
||||
|
||||
expect(onClickMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clickHandlerWithContextMenu opens popover when enableDrillDown is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelContextMenu({
|
||||
widget: mockWidget,
|
||||
queryResponse: mockQueryResponse,
|
||||
enableDrillDown: true,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.clickHandlerWithContextMenu(
|
||||
100,
|
||||
200,
|
||||
0,
|
||||
0,
|
||||
{ serviceName: 'svc' },
|
||||
{ queryName: 'A', inFocusOrNot: true },
|
||||
10,
|
||||
20,
|
||||
{},
|
||||
{ seriesIndex: 0, seriesName: 'A', value: 1, color: '#abc' },
|
||||
);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defaults to disabled when enableDrillDown is not provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelContextMenu({
|
||||
widget: mockWidget,
|
||||
queryResponse: mockQueryResponse,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.menuItemsConfig).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,13 @@ interface UseTimeSeriesContextMenuParams {
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export const usePanelContextMenu = ({
|
||||
widget,
|
||||
queryResponse,
|
||||
enableDrillDown = false,
|
||||
}: UseTimeSeriesContextMenuParams): {
|
||||
coordinates: { x: number; y: number } | null;
|
||||
popoverPosition: PopoverPosition | null;
|
||||
@@ -61,6 +63,9 @@ export const usePanelContextMenu = ({
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
if (!enableDrillDown) {
|
||||
return;
|
||||
}
|
||||
const [
|
||||
xValue,
|
||||
_yvalue,
|
||||
@@ -112,14 +117,14 @@ export const usePanelContextMenu = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[onClick, queryResponse],
|
||||
[enableDrillDown, onClick, queryResponse],
|
||||
);
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
onClose,
|
||||
menuItemsConfig,
|
||||
menuItemsConfig: enableDrillDown ? menuItemsConfig : {},
|
||||
clickHandlerWithContextMenu,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
enableDrillDown = false,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -61,6 +62,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
} = usePanelContextMenu({
|
||||
widget,
|
||||
queryResponse,
|
||||
enableDrillDown,
|
||||
});
|
||||
|
||||
const config = useMemo(() => {
|
||||
|
||||
@@ -31,6 +31,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
enableDrillDown = false,
|
||||
} = props;
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
@@ -60,6 +61,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
} = usePanelContextMenu({
|
||||
widget,
|
||||
queryResponse,
|
||||
enableDrillDown,
|
||||
});
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardVariablesV2 from '../DashboardVariablesV2';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.data?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.data?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.data?.metadata?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.data?.metadata?.tags],
|
||||
);
|
||||
const dashboardVariables = dashboard?.data?.spec?.variables ?? [];
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) setUpdatedTitle(title);
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
notifications.success({ message: 'Dashboard unlocked' });
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
notifications.success({ message: 'Dashboard locked' });
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
notifications.success({ message: 'Dashboard renamed successfully' });
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
notifications.info({
|
||||
message: 'V2 panel editor coming next',
|
||||
});
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', { ns: 'common' }),
|
||||
});
|
||||
}
|
||||
if (state.value) {
|
||||
notifications.success({ message: t('success', { ns: 'common' }) });
|
||||
}
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard?.data ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} className="tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
{dashboardVariables.length > 0 && (
|
||||
<section className="dashboard-variables">
|
||||
<DashboardVariablesV2
|
||||
dashboardId={id}
|
||||
variables={dashboardVariables}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -0,0 +1,227 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) return { key: trimmed, value: trimmed };
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.data?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.data?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.data?.metadata?.tags ?? []),
|
||||
[dashboard?.data?.metadata?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] = useState<string>(
|
||||
description,
|
||||
);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState<number>(
|
||||
0,
|
||||
);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/metadata/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/metadata/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
notifications.success({ message: 'Dashboard updated' });
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) n += 1;
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Collapse, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function CustomFields({ customValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-custom-section">
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<>
|
||||
<Input.TextArea
|
||||
value={customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
data-testid="variable-custom-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomFields;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import DynamicVariable from 'container/DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/DynamicVariable/DynamicVariable';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface Props {
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
onNameChange: (v: string) => void;
|
||||
onSignalChange: (v: TelemetrytypesSignalDTO | undefined) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// V1 DynamicVariable stores the source as a UI-friendly label:
|
||||
// 'All telemetry' | 'Logs' | 'Metrics' | 'Traces'. V2 stores the API enum
|
||||
// signal value: undefined (= all) | 'metrics' | 'traces' | 'logs'. We convert
|
||||
// at this boundary so the V1 component can stay untouched.
|
||||
const ALL_TELEMETRY = 'All telemetry';
|
||||
|
||||
function signalToV1Source(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): string {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) return 'Logs';
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) return 'Metrics';
|
||||
if (signal === TelemetrytypesSignalDTO.traces) return 'Traces';
|
||||
return ALL_TELEMETRY;
|
||||
}
|
||||
|
||||
function v1SourceToSignal(
|
||||
source: string,
|
||||
): TelemetrytypesSignalDTO | undefined {
|
||||
if (source === 'Logs') return TelemetrytypesSignalDTO.logs;
|
||||
if (source === 'Metrics') return TelemetrytypesSignalDTO.metrics;
|
||||
if (source === 'Traces') return TelemetrytypesSignalDTO.traces;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function DynamicFields({
|
||||
dynamicName,
|
||||
dynamicSignal,
|
||||
onNameChange,
|
||||
onSignalChange,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
const v1Value = useMemo(
|
||||
() => ({ name: dynamicName, value: signalToV1Source(dynamicSignal) }),
|
||||
[dynamicName, dynamicSignal],
|
||||
);
|
||||
|
||||
const setV1Value: React.Dispatch<
|
||||
React.SetStateAction<{ name: string; value: string } | undefined>
|
||||
> = useCallback(
|
||||
(action) => {
|
||||
const next =
|
||||
typeof action === 'function' ? action(v1Value) : action;
|
||||
if (!next) return;
|
||||
if (next.name !== dynamicName) onNameChange(next.name);
|
||||
const nextSignal = v1SourceToSignal(next.value);
|
||||
if (nextSignal !== dynamicSignal) onSignalChange(nextSignal);
|
||||
},
|
||||
[v1Value, dynamicName, dynamicSignal, onNameChange, onSignalChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setV1Value}
|
||||
dynamicVariablesSelectedValue={v1Value}
|
||||
errorAttributeKeyMessage={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicFields;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from 'antd';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
saving: boolean;
|
||||
canSave: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function Footer({ saving, canSave, onSave, onCancel }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-item-footer">
|
||||
<VariableItemRow>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={onCancel}
|
||||
icon={<X size={14} />}
|
||||
className="footer-btn-discard"
|
||||
disabled={saving}
|
||||
data-testid="variable-cancel-v2"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onSave}
|
||||
icon={<Check size={14} />}
|
||||
className="footer-btn-save"
|
||||
loading={saving}
|
||||
disabled={!canSave || saving}
|
||||
data-testid="variable-save-v2"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</VariableItemRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { V2VariableKind } from '../types';
|
||||
import AllOptionRow from './ListOptions/AllOptionRow';
|
||||
import CapturingRegexpRow from './ListOptions/CapturingRegexpRow';
|
||||
import CustomAllValueRow from './ListOptions/CustomAllValueRow';
|
||||
import DefaultValueRow from './ListOptions/DefaultValueRow';
|
||||
import MultiSelectRow from './ListOptions/MultiSelectRow';
|
||||
import SortRow from './ListOptions/SortRow';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
customAllValue: string;
|
||||
capturingRegexp: string;
|
||||
previewValues: string[];
|
||||
onAllowAllChange: (v: boolean) => void;
|
||||
onAllowMultipleChange: (v: boolean) => void;
|
||||
onSortChange: (v: string) => void;
|
||||
onDefaultValueChange: (v: string) => void;
|
||||
onCustomAllValueChange: (v: string) => void;
|
||||
onCapturingRegexpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ListBasicOptions({
|
||||
kind,
|
||||
allowAllValue,
|
||||
allowMultiple,
|
||||
sort,
|
||||
defaultValue,
|
||||
customAllValue,
|
||||
capturingRegexp,
|
||||
previewValues,
|
||||
onAllowAllChange,
|
||||
onAllowMultipleChange,
|
||||
onSortChange,
|
||||
onDefaultValueChange,
|
||||
onCustomAllValueChange,
|
||||
onCapturingRegexpChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SortRow sort={sort} onChange={onSortChange} />
|
||||
<MultiSelectRow
|
||||
allowMultiple={allowMultiple}
|
||||
onChange={(v): void => {
|
||||
onAllowMultipleChange(v);
|
||||
if (!v) onAllowAllChange(false);
|
||||
}}
|
||||
/>
|
||||
{allowMultiple && kind !== 'DYNAMIC' ? (
|
||||
<AllOptionRow
|
||||
allowAllValue={allowAllValue}
|
||||
onChange={onAllowAllChange}
|
||||
/>
|
||||
) : null}
|
||||
{allowAllValue ? (
|
||||
<CustomAllValueRow
|
||||
customAllValue={customAllValue}
|
||||
onChange={onCustomAllValueChange}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'QUERY' || kind === 'DYNAMIC' ? (
|
||||
<CapturingRegexpRow
|
||||
capturingRegexp={capturingRegexp}
|
||||
onChange={onCapturingRegexpChange}
|
||||
/>
|
||||
) : null}
|
||||
<DefaultValueRow
|
||||
kind={kind}
|
||||
defaultValue={defaultValue}
|
||||
previewValues={previewValues}
|
||||
onChange={onDefaultValueChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListBasicOptions;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowAllValue: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function AllOptionRow({ allowAllValue, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="all-option-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Include an option for ALL values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowAllValue}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-all-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default AllOptionRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
capturingRegexp: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CapturingRegexpRow({
|
||||
capturingRegexp,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="capturing-regexp-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Capturing regex
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Regex applied to each value; the first capture group becomes the
|
||||
selectable option.
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={capturingRegexp}
|
||||
placeholder="e.g. env-(.*)-\\d+"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-capturing-regexp-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CapturingRegexpRow;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customAllValue: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomAllValueRow({
|
||||
customAllValue,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="custom-all-value-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Custom "ALL" value
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Literal value emitted when the user picks ALL (e.g. * or .*).
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={customAllValue}
|
||||
placeholder="Leave blank to send the full union of values"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-custom-all-value-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomAllValueRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../../types';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
defaultValue: string;
|
||||
previewValues: string[];
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function DefaultValueRow({
|
||||
kind,
|
||||
defaultValue,
|
||||
previewValues,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
const description =
|
||||
kind === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value';
|
||||
|
||||
return (
|
||||
<VariableItemRow className="default-value-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
<Typography className="default-value-description">
|
||||
{description}
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<CustomSelect
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue}
|
||||
onChange={(v): void => onChange((v as string) ?? '')}
|
||||
options={previewValues.map((v) => ({ label: v, value: v }))}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultValueRow;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowMultiple: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function MultiSelectRow({ allowMultiple, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="multiple-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Enable multiple values to be checked
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowMultiple}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-multiple-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectRow;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import { SORT_OPTIONS } from '../../types';
|
||||
|
||||
interface Props {
|
||||
sort: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function SortRow({ sort, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="sort-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Sort Values</Typography>
|
||||
</LabelContainer>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={onChange}
|
||||
options={SORT_OPTIONS}
|
||||
className="sort-input"
|
||||
data-testid="variable-sort-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortRow;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string;
|
||||
onNameChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
nameError?: string;
|
||||
}
|
||||
|
||||
function NameDisplay({
|
||||
name,
|
||||
description,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
nameError,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VariableItemRow className="variable-name-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Name</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Unique name of the variable"
|
||||
value={name}
|
||||
className="name-input"
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
data-testid="variable-name-v2"
|
||||
/>
|
||||
{nameError ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{nameError}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
<VariableItemRow className="variable-description-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Description</Typography>
|
||||
</LabelContainer>
|
||||
<Input.TextArea
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
className="description-input"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
data-testid="variable-description-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameDisplay;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { orange } from '@ant-design/colors';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
previewValues: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function PreviewValues({ previewValues, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
<Typography className="typography-variables">
|
||||
Preview of Values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<div className="preview-values">
|
||||
{error ? (
|
||||
<Typography style={{ color: orange[5] }}>{error}</Typography>
|
||||
) : (
|
||||
previewValues.map((v, idx) => (
|
||||
<Tag key={`${v}${idx}`}>{v.toString()}</Tag>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewValues;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Editor from 'components/Editor';
|
||||
|
||||
import { LabelContainer } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
queryValue: string;
|
||||
onChange: (v: string) => void;
|
||||
onTestRun?: () => void;
|
||||
testRunLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function QueryFields({
|
||||
queryValue,
|
||||
onChange,
|
||||
onTestRun,
|
||||
testRunLoading,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
<Typography>Query</Typography>
|
||||
</LabelContainer>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={onChange}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
{onTestRun ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={onTestRun}
|
||||
style={{ position: 'absolute', bottom: 0 }}
|
||||
loading={testRunLoading}
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryFields;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function TextFields({ textValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-textbox-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={textValue}
|
||||
className="default-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-text-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextFields;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Button, Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../types';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
onChange: (kind: V2VariableKind) => void;
|
||||
}
|
||||
|
||||
function TypeSelector({ kind, onChange }: Props): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<VariableItemRow className="variable-type-section">
|
||||
<LabelContainer className="variable-type-label-container">
|
||||
<Typography className="typography-variables">Variable Type</Typography>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</LabelContainer>
|
||||
|
||||
<div className="variable-type-btn-group">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Pyramid size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'DYNAMIC' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
data-testid="variable-type-dynamic-v2"
|
||||
>
|
||||
Dynamic
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
|
||||
Beta
|
||||
</Tag>
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardType size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'TEXT' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
data-testid="variable-type-text-v2"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutList size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'CUSTOM' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
data-testid="variable-type-custom-v2"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
data-testid="variable-type-query-v2"
|
||||
>
|
||||
Query
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
|
||||
Not Recommended
|
||||
</Tag>
|
||||
<div onClick={(e): void => e.stopPropagation()}>
|
||||
<TextToolTip
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TypeSelector;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { draftToVariableDTO, validateDraft } from '../draft';
|
||||
import type { SaveCallback, VariableDraft, V2VariableKind } from '../types';
|
||||
import CustomFields from './CustomFields';
|
||||
import DynamicFields from './DynamicFields';
|
||||
import Footer from './Footer';
|
||||
import ListBasicOptions from './ListBasicOptions';
|
||||
import NameDisplay from './NameDisplay';
|
||||
import PreviewValues from './PreviewValues';
|
||||
import QueryFields from './QueryFields';
|
||||
import TextFields from './TextFields';
|
||||
import TypeSelector from './TypeSelector';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
initialDraft: VariableDraft;
|
||||
existingNames: string[];
|
||||
saving: boolean;
|
||||
onSave: SaveCallback;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor for a single V2 variable.
|
||||
*
|
||||
* Type-switch contract: changing `kind` does NOT clear the per-kind fields
|
||||
* the user already typed. They remain in local state and are restored if the
|
||||
* user navigates back to the same kind. Only the fields relevant to the
|
||||
* active `kind` are written into the V2 envelope on save (see
|
||||
* `draftToVariableDTO`).
|
||||
*/
|
||||
function VariableItem({
|
||||
initialDraft,
|
||||
existingNames,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<VariableDraft>(initialDraft);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof VariableDraft>(key: K, value: VariableDraft[K]): void => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onKindChange = useCallback(
|
||||
(kind: V2VariableKind): void => {
|
||||
// Retain every other field — only the discriminator changes.
|
||||
update('kind', kind);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const namesExcludingSelf = useMemo(
|
||||
() => existingNames.filter((n) => n !== initialDraft.name),
|
||||
[existingNames, initialDraft.name],
|
||||
);
|
||||
const validationError = useMemo(
|
||||
() => validateDraft(draft, namesExcludingSelf),
|
||||
[draft, namesExcludingSelf],
|
||||
);
|
||||
|
||||
// Local preview values — currently populated only for CUSTOM (CSV parse).
|
||||
// Query / Dynamic previews are wired in the variable execution subsystem.
|
||||
const previewValues = useMemo<string[]>(() => {
|
||||
if (draft.kind === 'CUSTOM') {
|
||||
return commaValuesParser(draft.customValue).map((v) => String(v));
|
||||
}
|
||||
return [];
|
||||
}, [draft.kind, draft.customValue]);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
if (validationError) return;
|
||||
onSave(draftToVariableDTO(draft));
|
||||
}, [draft, validationError, onSave]);
|
||||
|
||||
const errorFor = (
|
||||
field: NonNullable<typeof validationError>['field'],
|
||||
): string | undefined => {
|
||||
if (validationError && validationError.field === field) {
|
||||
return validationError.message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const showListOptions =
|
||||
draft.kind === 'QUERY' || draft.kind === 'CUSTOM' || draft.kind === 'DYNAMIC';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="variable-item-container">
|
||||
<div className="all-variables">
|
||||
<Button
|
||||
type="text"
|
||||
className="all-variables-btn"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
<div className="variable-item-content">
|
||||
<NameDisplay
|
||||
name={draft.name}
|
||||
description={draft.displayName}
|
||||
onNameChange={(v): void => update('name', v)}
|
||||
onDescriptionChange={(v): void => update('displayName', v)}
|
||||
nameError={errorFor('name')}
|
||||
/>
|
||||
|
||||
<TypeSelector kind={draft.kind} onChange={onKindChange} />
|
||||
|
||||
{draft.kind === 'DYNAMIC' ? (
|
||||
<DynamicFields
|
||||
dynamicName={draft.dynamicName}
|
||||
dynamicSignal={draft.dynamicSignal}
|
||||
onNameChange={(v): void => update('dynamicName', v)}
|
||||
onSignalChange={(v): void => update('dynamicSignal', v)}
|
||||
error={errorFor('dynamicName')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'QUERY' ? (
|
||||
<QueryFields
|
||||
queryValue={draft.queryValue}
|
||||
onChange={(v): void => update('queryValue', v)}
|
||||
error={errorFor('queryValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'CUSTOM' ? (
|
||||
<CustomFields
|
||||
customValue={draft.customValue}
|
||||
onChange={(v): void => update('customValue', v)}
|
||||
error={errorFor('customValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'TEXT' ? (
|
||||
<TextFields
|
||||
textValue={draft.textValue}
|
||||
onChange={(v): void => update('textValue', v)}
|
||||
error={errorFor('textValue')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showListOptions ? (
|
||||
<>
|
||||
<PreviewValues previewValues={previewValues} />
|
||||
<ListBasicOptions
|
||||
kind={draft.kind}
|
||||
allowAllValue={draft.allowAllValue}
|
||||
allowMultiple={draft.allowMultiple}
|
||||
sort={draft.sort}
|
||||
defaultValue={draft.defaultValue}
|
||||
customAllValue={draft.customAllValue}
|
||||
capturingRegexp={draft.capturingRegexp}
|
||||
previewValues={previewValues}
|
||||
onAllowAllChange={(v): void => update('allowAllValue', v)}
|
||||
onAllowMultipleChange={(v): void => update('allowMultiple', v)}
|
||||
onSortChange={(v): void => update('sort', v)}
|
||||
onDefaultValueChange={(v): void => update('defaultValue', v)}
|
||||
onCustomAllValueChange={(v): void =>
|
||||
update('customAllValue', v)
|
||||
}
|
||||
onCapturingRegexpChange={(v): void =>
|
||||
update('capturingRegexp', v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
saving={saving}
|
||||
canSave={!validationError}
|
||||
onSave={handleSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableItem;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import type { RowProps } from 'antd';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
|
||||
/**
|
||||
* Sortable table row that injects a drag handle into the `name` cell —
|
||||
* matches V1's [DashboardVariableSettings/index.tsx:31](TableRow component).
|
||||
*/
|
||||
function TableRow({ children, ...props }: RowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
// @ts-expect-error — antd Table's RowProps doesn't type the data-row-key it injects
|
||||
id: props['data-row-key'],
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...props.style,
|
||||
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, (child) => {
|
||||
const childElement = child as React.ReactElement;
|
||||
if (childElement.key === 'name') {
|
||||
return React.cloneElement(childElement, {
|
||||
key: 'name-with-drag',
|
||||
children: (
|
||||
<div className="variable-name-drag">
|
||||
<GripVertical
|
||||
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
|
||||
style={{ touchAction: 'none', cursor: 'move' }}
|
||||
size="md"
|
||||
{...listeners}
|
||||
/>
|
||||
{child}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
return childElement;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableRow;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Space, Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right cell of the variable table — description text + edit/delete actions.
|
||||
* Variable name + kind tag render in the left cell via column config.
|
||||
*/
|
||||
function VariableRow({
|
||||
description,
|
||||
kindLabel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-description-actions">
|
||||
<Typography.Text className="variable-description">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
<Space className="actions-btns">
|
||||
<Tag>{kindLabel}</Tag>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onEdit}
|
||||
className="edit-variable-button"
|
||||
data-testid="variable-edit-v2"
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onDelete}
|
||||
className="delete-variable-button"
|
||||
data-testid="variable-delete-v2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Empty, Table } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesVariableDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getVariableKindLabel, getVariableName } from '../draft';
|
||||
import TableRow from './TableRow';
|
||||
import VariableRow from './VariableRow';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardSettings.styles.scss';
|
||||
|
||||
interface TableEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onReorder: (next: DashboardtypesVariableDTO[]) => void;
|
||||
}
|
||||
|
||||
function VariableList({
|
||||
variables,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: Props): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>
|
||||
No variables yet. Click "Add variable" to create one.
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dataSource: TableEntry[] = variables.map((v, idx) => ({
|
||||
key: getVariableName(v) || String(idx),
|
||||
name: getVariableName(v),
|
||||
description:
|
||||
(v.spec as { display?: { name?: string } })?.display?.name ?? '',
|
||||
kindLabel: getVariableKindLabel(v),
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Variable',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
width: '50%',
|
||||
render: (entry: TableEntry): JSX.Element => (
|
||||
<VariableRow
|
||||
description={entry.description}
|
||||
kindLabel={entry.kindLabel}
|
||||
onEdit={(): void => onEdit(entry.index)}
|
||||
onDelete={(): void => onDelete(entry.index)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const fromIdx = dataSource.findIndex((d) => d.key === active.id);
|
||||
const toIdx = dataSource.findIndex((d) => d.key === over.id);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
onReorder(arrayMove(variables, fromIdx, toIdx));
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext items={dataSource.map((d) => d.key)}>
|
||||
<Table
|
||||
components={{ body: { row: TableRow } }}
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
dataSource={dataSource}
|
||||
className="dashboard-variable-settings-table"
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableList;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { V2VariableKind, VariableDraft } from './types';
|
||||
|
||||
export function emptyDraft(): VariableDraft {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
kind: 'QUERY',
|
||||
name: '',
|
||||
displayName: '',
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
defaultValue: '',
|
||||
customAllValue: '',
|
||||
capturingRegexp: '',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
dynamicName: '',
|
||||
dynamicSignal: undefined,
|
||||
textValue: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the relevant slot from a V2 envelope; other slots stay empty.
|
||||
*/
|
||||
export function variableDTOToDraft(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableDraft {
|
||||
const base = emptyDraft();
|
||||
if (dto.kind === 'TextVariable') {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...base,
|
||||
kind: 'TEXT',
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
textValue: spec?.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ListVariable
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
let kind: V2VariableKind = 'QUERY';
|
||||
if (pluginKind === 'signoz/DynamicVariable') kind = 'DYNAMIC';
|
||||
else if (pluginKind === 'signoz/CustomVariable') kind = 'CUSTOM';
|
||||
else if (pluginKind === 'signoz/QueryVariable') kind = 'QUERY';
|
||||
|
||||
const draft: VariableDraft = {
|
||||
...base,
|
||||
kind,
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
allowAllValue: !!spec?.allowAllValue,
|
||||
allowMultiple: !!spec?.allowMultiple,
|
||||
sort: spec?.sort ?? 'none',
|
||||
defaultValue: typeof spec?.defaultValue === 'string' ? spec.defaultValue : '',
|
||||
customAllValue: spec?.customAllValue ?? '',
|
||||
capturingRegexp: spec?.capturingRegexp ?? '',
|
||||
};
|
||||
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (kind === 'QUERY') {
|
||||
draft.queryValue = (pluginSpec?.queryValue as string) ?? '';
|
||||
} else if (kind === 'CUSTOM') {
|
||||
draft.customValue = (pluginSpec?.customValue as string) ?? '';
|
||||
} else if (kind === 'DYNAMIC') {
|
||||
draft.dynamicName = (pluginSpec?.name as string) ?? '';
|
||||
draft.dynamicSignal = pluginSpec?.signal as TelemetrytypesSignalDTO | undefined;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize draft to a V2 envelope, reading ONLY the fields relevant to the
|
||||
* active kind. Other fields the user touched stay in React state and are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function draftToVariableDTO(
|
||||
draft: VariableDraft,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = draft.displayName ? { name: draft.displayName } : undefined;
|
||||
|
||||
if (draft.kind === 'TEXT') {
|
||||
return ({
|
||||
kind: 'TextVariable',
|
||||
spec: {
|
||||
name: draft.name,
|
||||
display,
|
||||
value: draft.textValue,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
let plugin: DashboardtypesVariablePluginDTO | undefined;
|
||||
if (draft.kind === 'QUERY') {
|
||||
plugin = ({
|
||||
kind: 'signoz/QueryVariable',
|
||||
spec: { queryValue: draft.queryValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'CUSTOM') {
|
||||
plugin = ({
|
||||
kind: 'signoz/CustomVariable',
|
||||
spec: { customValue: draft.customValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'DYNAMIC') {
|
||||
plugin = ({
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: {
|
||||
name: draft.dynamicName,
|
||||
signal: draft.dynamicSignal,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
}
|
||||
|
||||
const spec: DashboardtypesListVariableSpecDTO = {
|
||||
name: draft.name,
|
||||
display,
|
||||
allowAllValue: draft.allowAllValue,
|
||||
allowMultiple: draft.allowMultiple,
|
||||
sort: draft.sort,
|
||||
plugin,
|
||||
// VariableDefaultValueDTO is an open `{[key]: unknown}` shape, so a bare
|
||||
// string isn't structurally assignable. We cast at the boundary.
|
||||
defaultValue: draft.defaultValue
|
||||
? ((draft.defaultValue as unknown) as DashboardtypesListVariableSpecDTO['defaultValue'])
|
||||
: undefined,
|
||||
customAllValue: draft.customAllValue || undefined,
|
||||
capturingRegexp: draft.capturingRegexp || undefined,
|
||||
};
|
||||
|
||||
return ({
|
||||
kind: 'ListVariable',
|
||||
spec,
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
export interface DraftValidationError {
|
||||
field:
|
||||
| 'name'
|
||||
| 'queryValue'
|
||||
| 'customValue'
|
||||
| 'dynamicName'
|
||||
| 'textValue'
|
||||
| 'cycle';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function validateDraft(
|
||||
draft: VariableDraft,
|
||||
existingNames: string[],
|
||||
): DraftValidationError | null {
|
||||
const trimmedName = draft.name.trim();
|
||||
if (!trimmedName) {
|
||||
return { field: 'name', message: 'Variable name is required' };
|
||||
}
|
||||
if (/\s/.test(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name cannot contain whitespace' };
|
||||
}
|
||||
if (existingNames.includes(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name already exists' };
|
||||
}
|
||||
|
||||
if (draft.kind === 'QUERY' && !draft.queryValue.trim()) {
|
||||
return { field: 'queryValue', message: 'Query is required' };
|
||||
}
|
||||
if (draft.kind === 'CUSTOM' && !draft.customValue.trim()) {
|
||||
return { field: 'customValue', message: 'Custom values are required' };
|
||||
}
|
||||
if (draft.kind === 'DYNAMIC' && !draft.dynamicName.trim()) {
|
||||
return { field: 'dynamicName', message: 'Attribute name is required' };
|
||||
}
|
||||
if (draft.kind === 'TEXT' && !draft.textValue.trim()) {
|
||||
return { field: 'textValue', message: 'Default text value is required' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVariableName(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') {
|
||||
return (dto.spec as DashboardTextVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
return (dto.spec as DashboardtypesListVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
|
||||
export function getVariableKindLabel(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') return 'Text';
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
if (pluginKind === 'signoz/DynamicVariable') return 'Dynamic';
|
||||
if (pluginKind === 'signoz/CustomVariable') return 'Custom';
|
||||
return 'Query';
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
buildDependencyMap,
|
||||
detectCycle,
|
||||
} from '../../DashboardVariablesV2/dependencyGraph';
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
import {
|
||||
emptyDraft,
|
||||
getVariableName,
|
||||
variableDTOToDraft,
|
||||
} from './draft';
|
||||
import type { VariableDraft } from './types';
|
||||
import VariableItem from './VariableItem';
|
||||
import VariableList from './VariableList';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { kind: 'closed' }
|
||||
| { kind: 'add'; draft: VariableDraft }
|
||||
| { kind: 'edit'; index: number; draft: VariableDraft };
|
||||
|
||||
function VariablesSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const variables = useMemo<DashboardtypesVariableDTO[]>(
|
||||
() => dashboard?.data?.spec?.variables ?? [],
|
||||
[dashboard?.data?.spec?.variables],
|
||||
);
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ kind: 'closed' });
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const existingNames = useMemo(() => variables.map(getVariableName), [
|
||||
variables,
|
||||
]);
|
||||
|
||||
const persistVariables = useCallback(
|
||||
async (next: DashboardtypesVariableDTO[]): Promise<void> => {
|
||||
if (!dashboardId) return;
|
||||
const cycle = detectCycle(buildDependencyMap(next));
|
||||
if (cycle.hasCycle) {
|
||||
notifications.error({
|
||||
message: `Cyclic variable dependency: ${cycle.cycle?.join(' → ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id: dashboardId }, patch);
|
||||
notifications.success({ message: 'Variables updated' });
|
||||
onRefetch();
|
||||
setEditor({ kind: 'closed' });
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, notifications, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (dto: DashboardtypesVariableDTO): Promise<void> => {
|
||||
if (editor.kind === 'add') {
|
||||
await persistVariables([...variables, dto]);
|
||||
} else if (editor.kind === 'edit') {
|
||||
const next = variables.slice();
|
||||
next[editor.index] = dto;
|
||||
await persistVariables(next);
|
||||
}
|
||||
},
|
||||
[editor, variables, persistVariables],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (index: number): Promise<void> => {
|
||||
const next = variables.slice();
|
||||
next.splice(index, 1);
|
||||
await persistVariables(next);
|
||||
},
|
||||
[variables, persistVariables],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{editor.kind === 'closed' ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
setEditor({ kind: 'add', draft: emptyDraft() })
|
||||
}
|
||||
data-testid="add-variable-v2"
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
<VariableList
|
||||
variables={variables}
|
||||
onEdit={(index): void =>
|
||||
setEditor({
|
||||
kind: 'edit',
|
||||
index,
|
||||
draft: variableDTOToDraft(variables[index]),
|
||||
})
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
onReorder={persistVariables}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<VariableItem
|
||||
initialDraft={editor.draft}
|
||||
existingNames={existingNames}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onCancel={(): void => setEditor({ kind: 'closed' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettingsV2;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2VariableKind = 'QUERY' | 'CUSTOM' | 'DYNAMIC' | 'TEXT';
|
||||
|
||||
/**
|
||||
* Internal editor state. Holds every per-kind field so that switching `kind`
|
||||
* does not discard user input. Only the fields relevant to the active kind
|
||||
* are written into the resulting V2 envelope on save.
|
||||
*/
|
||||
export interface VariableDraft {
|
||||
id: string; // local identifier for list keys; not persisted to V2
|
||||
kind: V2VariableKind;
|
||||
name: string;
|
||||
displayName: string;
|
||||
|
||||
// Shared by all List variants (QUERY / CUSTOM / DYNAMIC)
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
// V2-only: literal value emitted when the user picks "ALL"
|
||||
customAllValue: string;
|
||||
// V2-only: regex applied to query/dynamic results to extract the actual value
|
||||
capturingRegexp: string;
|
||||
|
||||
// QUERY
|
||||
queryValue: string;
|
||||
|
||||
// CUSTOM
|
||||
customValue: string;
|
||||
|
||||
// DYNAMIC
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
// TEXT
|
||||
textValue: string;
|
||||
}
|
||||
|
||||
export type SaveCallback = (dto: DashboardtypesVariableDTO) => void;
|
||||
|
||||
export const VARIABLE_KIND_LABEL: Record<V2VariableKind, string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
DYNAMIC: 'Dynamic',
|
||||
TEXT: 'Text',
|
||||
};
|
||||
|
||||
// V2 supports a finer sort taxonomy than V1: separate alphabetical and
|
||||
// numerical orderings (V1 only exposed Disabled / Ascending / Descending).
|
||||
// Values match the strings used in the perses fixture and backend.
|
||||
export const SORT_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Disabled', value: 'none' },
|
||||
{ label: 'Alphabetical ascending', value: 'alphabetical-asc' },
|
||||
{ label: 'Alphabetical descending', value: 'alphabetical-desc' },
|
||||
{ label: 'Numerical ascending', value: 'numerical-asc' },
|
||||
{ label: 'Numerical descending', value: 'numerical-desc' },
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import VariablesSettingsV2 from './Variables';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: (
|
||||
<VariablesSettingsV2 dashboard={dashboard} onRefetch={onRefetch} />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { referencedVariables } from './substitution';
|
||||
|
||||
/**
|
||||
* Extracts the strings on a variable that may contain `$var` references —
|
||||
* i.e. the dependency edges out of this variable.
|
||||
*
|
||||
* Currently only QUERY variables produce dependencies (their `queryValue`
|
||||
* may reference other variables). CUSTOM and DYNAMIC plugin specs don't
|
||||
* embed substitutable strings, and TEXT variables are leaf nodes.
|
||||
*/
|
||||
function dependencyStrings(dto: DashboardtypesVariableDTO): string[] {
|
||||
if (dto.kind !== 'ListVariable') return [];
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (pluginKind === 'signoz/QueryVariable') {
|
||||
return [String(pluginSpec?.queryValue ?? '')];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function nameOf(dto: DashboardtypesVariableDTO): string {
|
||||
return (dto.spec as { name?: string })?.name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct dependencies for each variable (name → set of names it references).
|
||||
*/
|
||||
export function buildDependencyMap(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): Record<string, Set<string>> {
|
||||
const knownNames = new Set(variables.map(nameOf).filter(Boolean));
|
||||
const deps: Record<string, Set<string>> = {};
|
||||
variables.forEach((v) => {
|
||||
const name = nameOf(v);
|
||||
if (!name) return;
|
||||
const refs = new Set<string>();
|
||||
dependencyStrings(v).forEach((s) => {
|
||||
referencedVariables(s).forEach((ref) => {
|
||||
if (ref !== name && knownNames.has(ref)) refs.add(ref);
|
||||
});
|
||||
});
|
||||
deps[name] = refs;
|
||||
});
|
||||
return deps;
|
||||
}
|
||||
|
||||
export interface CycleResult {
|
||||
hasCycle: boolean;
|
||||
cycle?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a cycle via DFS; returns the participating names in traversal order.
|
||||
* Used at save time and to guard re-resolution.
|
||||
*/
|
||||
export function detectCycle(
|
||||
deps: Record<string, Set<string>>,
|
||||
): CycleResult {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color: Record<string, number> = {};
|
||||
const stack: string[] = [];
|
||||
const names = Object.keys(deps);
|
||||
names.forEach((n) => {
|
||||
color[n] = WHITE;
|
||||
});
|
||||
|
||||
function visit(node: string): string[] | null {
|
||||
color[node] = GRAY;
|
||||
stack.push(node);
|
||||
for (const next of deps[node] ?? []) {
|
||||
if (color[next] === GRAY) {
|
||||
const idx = stack.indexOf(next);
|
||||
return stack.slice(idx).concat(next);
|
||||
}
|
||||
if (color[next] === WHITE) {
|
||||
const found = visit(next);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
color[node] = BLACK;
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
if (color[n] === WHITE) {
|
||||
const cycle = visit(n);
|
||||
if (cycle) return { hasCycle: true, cycle };
|
||||
}
|
||||
}
|
||||
return { hasCycle: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kahn's algorithm — returns variable names in dependency order
|
||||
* (dependencies first). If there's a cycle the result excludes the
|
||||
* participating nodes; combine with `detectCycle` for validation.
|
||||
*/
|
||||
export function topoSort(
|
||||
deps: Record<string, Set<string>>,
|
||||
): string[] {
|
||||
const incoming: Record<string, number> = {};
|
||||
const downstream: Record<string, string[]> = {};
|
||||
Object.keys(deps).forEach((n) => {
|
||||
incoming[n] = 0;
|
||||
downstream[n] = [];
|
||||
});
|
||||
Object.entries(deps).forEach(([n, refs]) => {
|
||||
refs.forEach((ref) => {
|
||||
incoming[n] += 1;
|
||||
downstream[ref] = downstream[ref] ?? [];
|
||||
downstream[ref].push(n);
|
||||
});
|
||||
});
|
||||
|
||||
const queue: string[] = Object.keys(incoming).filter((n) => incoming[n] === 0);
|
||||
const out: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const n = queue.shift() as string;
|
||||
out.push(n);
|
||||
(downstream[n] ?? []).forEach((next) => {
|
||||
incoming[next] -= 1;
|
||||
if (incoming[next] === 0) queue.push(next);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { buildDependencyMap, detectCycle, topoSort } from './dependencyGraph';
|
||||
import VariableSelector from './selectors/VariableSelector';
|
||||
import { useVariableSelectionStore } from './state/selectionStore';
|
||||
|
||||
import '../../DashboardContainer/DashboardVariablesSelection/DashboardVariableSelection.styles.scss';
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
variables: DashboardtypesVariableDTO[] | undefined;
|
||||
}
|
||||
|
||||
function nameOf(v: DashboardtypesVariableDTO): string {
|
||||
return (
|
||||
(v.spec as DashboardtypesListVariableSpecDTO | DashboardTextVariableSpecDTO)
|
||||
?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function kindHint(v: DashboardtypesVariableDTO): 'list' | 'text' {
|
||||
return v.kind === 'TextVariable' ? 'text' : 'list';
|
||||
}
|
||||
|
||||
function DashboardVariablesV2({ dashboardId, variables }: Props): JSX.Element | null {
|
||||
const hydrate = useVariableSelectionStore((s) => s.hydrate);
|
||||
|
||||
// Build hints map (variable-name → list/text) so the store can decode the URL.
|
||||
const hints = useMemo<Record<string, 'list' | 'text'>>(() => {
|
||||
const out: Record<string, 'list' | 'text'> = {};
|
||||
(variables ?? []).forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) out[n] = kindHint(v);
|
||||
});
|
||||
return out;
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
hydrate(dashboardId, hints);
|
||||
}, [dashboardId, hints, hydrate]);
|
||||
|
||||
// Sort variables in dependency order so dependent resolvers see fresh
|
||||
// selections from their parents. (Render order doesn't affect the React
|
||||
// Query cache but it does affect *visual* order.)
|
||||
const ordered = useMemo(() => {
|
||||
if (!variables?.length) return [];
|
||||
const deps = buildDependencyMap(variables);
|
||||
const cycle = detectCycle(deps);
|
||||
if (cycle.hasCycle) {
|
||||
// Render in the original order; the cycle is surfaced separately at save
|
||||
// time via validateDraft. Resolution will still execute; it just won't
|
||||
// converge.
|
||||
return variables;
|
||||
}
|
||||
const order = topoSort(deps);
|
||||
const byName: Record<string, DashboardtypesVariableDTO> = {};
|
||||
variables.forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) byName[n] = v;
|
||||
});
|
||||
return order.map((n) => byName[n]).filter(Boolean);
|
||||
}, [variables]);
|
||||
|
||||
if (!variables || variables.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="variables-container">
|
||||
{ordered.map((v) => (
|
||||
<VariableSelector key={nameOf(v)} variable={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardVariablesV2;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Applies V2 `capturingRegexp` to each value: if the regex matches and has a
|
||||
* capture group, replace the value with the first capture; otherwise keep
|
||||
* the raw value. Invalid regex silently passes values through.
|
||||
*
|
||||
* Empty results (no match at all) are filtered out — they would be useless
|
||||
* as selectable options.
|
||||
*/
|
||||
export function applyCapturingRegexp(
|
||||
values: string[],
|
||||
pattern: string | undefined | null,
|
||||
): string[] {
|
||||
if (!pattern) return values;
|
||||
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern);
|
||||
} catch {
|
||||
return values;
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
values.forEach((v) => {
|
||||
const m = re.exec(v);
|
||||
if (!m) return;
|
||||
out.push(m[1] !== undefined ? m[1] : m[0]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Apply V2 sort modes to a resolved value list.
|
||||
*
|
||||
* Sort values come from the perses spec — `none`, `alphabetical-asc`,
|
||||
* `alphabetical-desc`, `numerical-asc`, `numerical-desc`. Numerical sort
|
||||
* falls back to string compare for values that aren't numbers so we never
|
||||
* throw away non-numeric entries.
|
||||
*/
|
||||
export function applySort(
|
||||
values: string[],
|
||||
sort: string | null | undefined,
|
||||
): string[] {
|
||||
if (!sort || sort === 'none' || values.length <= 1) return values;
|
||||
const copy = values.slice();
|
||||
if (sort === 'alphabetical-asc') {
|
||||
copy.sort((a, b) => a.localeCompare(b));
|
||||
} else if (sort === 'alphabetical-desc') {
|
||||
copy.sort((a, b) => b.localeCompare(a));
|
||||
} else if (sort === 'numerical-asc' || sort === 'numerical-desc') {
|
||||
copy.sort((a, b) => {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
const aFinite = Number.isFinite(na);
|
||||
const bFinite = Number.isFinite(nb);
|
||||
if (aFinite && bFinite) {
|
||||
return sort === 'numerical-asc' ? na - nb : nb - na;
|
||||
}
|
||||
// Mixed numeric/non-numeric: keep non-numerics at the end, sorted alpha.
|
||||
if (aFinite) return -1;
|
||||
if (bFinite) return 1;
|
||||
return sort === 'numerical-asc'
|
||||
? a.localeCompare(b)
|
||||
: b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Output of resolving a single list variable. Text variables don't go
|
||||
* through resolution — their value is the literal string.
|
||||
*/
|
||||
export interface ResolvedValues {
|
||||
values: string[];
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const idle: ResolvedValues = { values: [], status: 'idle' };
|
||||
export const loading: ResolvedValues = { values: [], status: 'loading' };
|
||||
export function success(values: string[]): ResolvedValues {
|
||||
return { values, status: 'success' };
|
||||
}
|
||||
export function failure(error: string): ResolvedValues {
|
||||
return { values: [], status: 'error', error };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* CUSTOM variables: the comma-separated user input is the value list.
|
||||
* No network call, purely client-side.
|
||||
*/
|
||||
export function useCustomResolver(customValue: string): ResolvedValues {
|
||||
return useMemo(
|
||||
() => success(commaValuesParser(customValue).map((v) => String(v))),
|
||||
[customValue],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
function signalToV1(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): 'traces' | 'logs' | 'metrics' | undefined {
|
||||
if (signal === TelemetrytypesSignalDTO.traces) return 'traces';
|
||||
if (signal === TelemetrytypesSignalDTO.logs) return 'logs';
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) return 'metrics';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* DYNAMIC variables: telemetry attribute lookup.
|
||||
* - `signal === undefined` → search across all telemetry types.
|
||||
* - Otherwise scoped to the specific signal.
|
||||
*
|
||||
* Uses the existing V1 hook directly; the API is V2-shape-agnostic.
|
||||
*/
|
||||
export function useDynamicResolver(
|
||||
attributeName: string,
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): ResolvedValues {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const enabled = !!attributeName;
|
||||
const { data, isLoading, isError, error } = useGetFieldValues({
|
||||
signal: signalToV1(signal),
|
||||
name: attributeName,
|
||||
enabled,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
});
|
||||
|
||||
if (!enabled) return idle;
|
||||
if (isLoading) return loading;
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as Error)?.message ?? 'Failed to resolve dynamic variable',
|
||||
);
|
||||
}
|
||||
return success(data?.data?.normalizedValues ?? []);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import { substituteVariables } from '../substitution';
|
||||
import type { SelectionsByName } from '../state/types';
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* Reduce the user's V2 selections to the V1 `PayloadVariables` shape the
|
||||
* variables/query endpoint expects (a plain name → selected-value map).
|
||||
*/
|
||||
function selectionsToPayload(
|
||||
selections: SelectionsByName,
|
||||
): PayloadVariables {
|
||||
const out: PayloadVariables = {};
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) return;
|
||||
if (sel.kind === 'text') {
|
||||
out[name] = sel.value;
|
||||
} else if (sel.allSelected) {
|
||||
// Endpoint understands `__ALL__`-style markers via the substitution
|
||||
// done client-side; leave the value out so server doesn't double up.
|
||||
// (Callers using IN ($var) expand via substituteVariables instead.)
|
||||
} else if (sel.values.length === 1) {
|
||||
out[name] = sel.values[0];
|
||||
} else {
|
||||
out[name] = sel.values;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
interface UseQueryResolverArgs {
|
||||
variableName: string;
|
||||
queryValue: string;
|
||||
selections: SelectionsByName;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY variables: substitute `$var` references using current selections,
|
||||
* then POST to `/api/v2/variables/query`. React Query caches per
|
||||
* (name, substitutedQuery) so re-render with the same inputs reuses results.
|
||||
*/
|
||||
export function useQueryResolver({
|
||||
variableName,
|
||||
queryValue,
|
||||
selections,
|
||||
enabled,
|
||||
}: UseQueryResolverArgs): ResolvedValues {
|
||||
const substituted = substituteVariables(queryValue, selections);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['v2-variable-query', variableName, substituted],
|
||||
queryFn: () =>
|
||||
dashboardVariablesQuery({
|
||||
query: substituted,
|
||||
variables: selectionsToPayload(selections),
|
||||
}),
|
||||
enabled: enabled && !!substituted,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!enabled || !substituted) return idle;
|
||||
if (isLoading) return loading;
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as { details?: { error?: string } })?.details?.error ??
|
||||
(error as Error)?.message ??
|
||||
'Variable query failed',
|
||||
);
|
||||
}
|
||||
const payload = (data as { payload?: { variableValues?: unknown[] } } | undefined)
|
||||
?.payload;
|
||||
const values = (payload?.variableValues ?? []).map((v) => String(v));
|
||||
return success(values);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import { applyCapturingRegexp } from './capturingRegexp';
|
||||
import { applySort } from './sorting';
|
||||
import { useCustomResolver } from './useCustomResolver';
|
||||
import { useDynamicResolver } from './useDynamicResolver';
|
||||
import { useQueryResolver } from './useQueryResolver';
|
||||
import { idle, success, type ResolvedValues } from './types';
|
||||
|
||||
interface UseResolveVariableArgs {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a variable to the correct resolver hook and applies the V2
|
||||
* post-processing pipeline:
|
||||
*
|
||||
* raw values → capturingRegexp → sort → final list
|
||||
*
|
||||
* Text variables short-circuit since they don't have a value list.
|
||||
*/
|
||||
export function useResolveVariable({
|
||||
variable,
|
||||
}: UseResolveVariableArgs): ResolvedValues {
|
||||
const selections = useVariableSelectionStore((s) => s.selections);
|
||||
|
||||
// Read all fields up front so the React Query / hook order is stable
|
||||
// across renders (hooks must not be called conditionally).
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const listSpec = (variable.spec as DashboardtypesListVariableSpecDTO) ?? {};
|
||||
const pluginKind = listSpec.plugin?.kind;
|
||||
const pluginSpec = (listSpec.plugin?.spec as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
const name = listSpec?.name ?? '';
|
||||
const customValue = (pluginSpec.customValue as string) ?? '';
|
||||
const queryValue = (pluginSpec.queryValue as string) ?? '';
|
||||
const dynName = (pluginSpec.name as string) ?? '';
|
||||
const dynSignal = pluginSpec.signal as TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
const customRes = useCustomResolver(
|
||||
pluginKind === 'signoz/CustomVariable' ? customValue : '',
|
||||
);
|
||||
const dynRes = useDynamicResolver(
|
||||
pluginKind === 'signoz/DynamicVariable' ? dynName : '',
|
||||
dynSignal,
|
||||
);
|
||||
const queryRes = useQueryResolver({
|
||||
variableName: name,
|
||||
queryValue: pluginKind === 'signoz/QueryVariable' ? queryValue : '',
|
||||
selections,
|
||||
enabled: pluginKind === 'signoz/QueryVariable',
|
||||
});
|
||||
|
||||
const raw: ResolvedValues = useMemo(() => {
|
||||
if (isText) return success([]);
|
||||
if (pluginKind === 'signoz/CustomVariable') return customRes;
|
||||
if (pluginKind === 'signoz/DynamicVariable') return dynRes;
|
||||
if (pluginKind === 'signoz/QueryVariable') return queryRes;
|
||||
return idle;
|
||||
}, [isText, pluginKind, customRes, dynRes, queryRes]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (raw.status !== 'success') return raw;
|
||||
const afterRegex = applyCapturingRegexp(raw.values, listSpec.capturingRegexp);
|
||||
const afterSort = applySort(afterRegex, listSpec.sort);
|
||||
return success(afterSort);
|
||||
}, [raw, listSpec.capturingRegexp, listSpec.sort]);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import SelectVariableInput from 'container/DashboardContainer/DashboardVariablesSelection/SelectVariableInput';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { ResolvedValues } from '../resolution/types';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
|
||||
interface Props {
|
||||
variableId: string;
|
||||
resolved: ResolvedValues;
|
||||
selection: VariableSelection | undefined;
|
||||
allowMultiple: boolean;
|
||||
allowAllValue: boolean;
|
||||
defaultValue: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function selectionToValue(
|
||||
selection: VariableSelection | undefined,
|
||||
defaultValue: string,
|
||||
allowMultiple: boolean,
|
||||
): string | string[] | undefined {
|
||||
if (selection && selection.kind === 'list') {
|
||||
if (selection.allSelected) return ALL_SELECT_VALUE;
|
||||
if (allowMultiple) return selection.values;
|
||||
return selection.values[0];
|
||||
}
|
||||
if (defaultValue) return allowMultiple ? [defaultValue] : defaultValue;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY / CUSTOM / DYNAMIC variables share the same dropdown UX: a list of
|
||||
* options + an optional ALL entry + single / multi-select. Reuses V1's
|
||||
* `SelectVariableInput` so visuals match exactly.
|
||||
*/
|
||||
function ListVariableSelector({
|
||||
variableId,
|
||||
resolved,
|
||||
selection,
|
||||
allowMultiple,
|
||||
allowAllValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onClear,
|
||||
}: Props): JSX.Element {
|
||||
const options = useMemo(
|
||||
() => resolved.values.map((v) => ({ label: v, value: v })),
|
||||
[resolved.values],
|
||||
);
|
||||
|
||||
const value = selectionToValue(selection, defaultValue, allowMultiple);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableId}
|
||||
options={options}
|
||||
value={value}
|
||||
enableSelectAll={allowAllValue}
|
||||
isMultiSelect={allowMultiple}
|
||||
loading={resolved.status === 'loading'}
|
||||
errorMessage={resolved.error ?? null}
|
||||
onChange={(next): void => {
|
||||
if (Array.isArray(next)) {
|
||||
// Multi-select. Antd's CustomMultiSelect emits the ALL sentinel
|
||||
// when the user toggles the "Select all" row.
|
||||
const hasAll = next.includes(ALL_SELECT_VALUE);
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: hasAll ? [] : next,
|
||||
allSelected: hasAll,
|
||||
});
|
||||
} else if (next === ALL_SELECT_VALUE) {
|
||||
onChange({ kind: 'list', values: [], allSelected: true });
|
||||
} else {
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: next ? [next] : [],
|
||||
allSelected: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClear={onClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableSelector;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1-style label: `$name` + an info tooltip if a description is set.
|
||||
* Mirrors [DashboardVariablesSelection/VariableItem.tsx:34-42](V1).
|
||||
*/
|
||||
function SelectorLabel({ name, description }: Props): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className="variable-name" truncate={1}>
|
||||
${name}
|
||||
{description ? (
|
||||
<Tooltip title={description}>
|
||||
<SolidInfoCircle className="info-icon" size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectorLabel;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text variable input — commits on blur (and on Enter), matching V1's
|
||||
* `TextboxVariableInput` UX which avoids re-fetching panels on every
|
||||
* keystroke.
|
||||
*/
|
||||
function TextVariableSelector({ value, onCommit }: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const commit = (): void => {
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="variable-select"
|
||||
value={draft}
|
||||
onChange={(e): void => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onPressEnter={commit}
|
||||
data-testid="text-variable-input-v2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextVariableSelector;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useResolveVariable } from '../resolution/useResolveVariable';
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
import ListVariableSelector from './ListVariableSelector';
|
||||
import SelectorLabel from './SelectorLabel';
|
||||
import TextVariableSelector from './TextVariableSelector';
|
||||
|
||||
interface Props {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one variable to its kind-specific selector. Owns the selection
|
||||
* store binding so the kind-specific components stay dumb.
|
||||
*/
|
||||
function VariableSelector({ variable }: Props): JSX.Element | null {
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const spec = variable.spec as
|
||||
| DashboardtypesListVariableSpecDTO
|
||||
| DashboardTextVariableSpecDTO
|
||||
| undefined;
|
||||
const name = spec?.name ?? '';
|
||||
|
||||
const selection = useVariableSelectionStore((s) =>
|
||||
name ? s.selections[name] : undefined,
|
||||
);
|
||||
const setSelection = useVariableSelectionStore((s) => s.setSelection);
|
||||
const resolved = useResolveVariable({ variable });
|
||||
|
||||
const setListSelection = useCallback(
|
||||
(next: VariableSelection): void => setSelection(name, next),
|
||||
[name, setSelection],
|
||||
);
|
||||
const clearSelection = useCallback((): void => setSelection(name, undefined), [
|
||||
name,
|
||||
setSelection,
|
||||
]);
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
const description = spec?.display?.name ?? '';
|
||||
|
||||
if (isText) {
|
||||
const textSpec = spec as DashboardTextVariableSpecDTO;
|
||||
const current =
|
||||
selection?.kind === 'text' ? selection.value : textSpec?.value ?? '';
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<TextVariableSelector
|
||||
value={current}
|
||||
onCommit={(v): void => setSelection(name, { kind: 'text', value: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listSpec = spec as DashboardtypesListVariableSpecDTO;
|
||||
const defaultValue =
|
||||
typeof listSpec?.defaultValue === 'string'
|
||||
? (listSpec.defaultValue as string)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<ListVariableSelector
|
||||
variableId={name}
|
||||
resolved={resolved}
|
||||
selection={selection}
|
||||
allowMultiple={!!listSpec?.allowMultiple}
|
||||
allowAllValue={!!listSpec?.allowAllValue}
|
||||
defaultValue={defaultValue}
|
||||
onChange={setListSelection}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SelectionsByName } from './types';
|
||||
|
||||
const STORAGE_PREFIX = 'dashboard-v2-variables';
|
||||
|
||||
function storageKey(dashboardId: string): string {
|
||||
return `${STORAGE_PREFIX}:${dashboardId}`;
|
||||
}
|
||||
|
||||
export function loadSelectionsFromStorage(
|
||||
dashboardId: string,
|
||||
): SelectionsByName {
|
||||
if (!dashboardId) return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(dashboardId));
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as SelectionsByName;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSelectionsToStorage(
|
||||
dashboardId: string,
|
||||
selections: SelectionsByName,
|
||||
): void {
|
||||
if (!dashboardId) return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey(dashboardId),
|
||||
JSON.stringify(selections),
|
||||
);
|
||||
} catch {
|
||||
// quota / availability issues — selection still lives in memory + URL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
loadSelectionsFromStorage,
|
||||
saveSelectionsToStorage,
|
||||
} from './localStorage';
|
||||
import type { SelectionsByName, VariableSelection } from './types';
|
||||
import { readSelectionsFromUrl, writeSelectionsToUrl } from './urlSync';
|
||||
|
||||
interface SelectionStoreState {
|
||||
dashboardId: string;
|
||||
selections: SelectionsByName;
|
||||
|
||||
/**
|
||||
* Hydrate from URL → fallback to LocalStorage. Called once per dashboard
|
||||
* load. `hints` lets URL decoding pick list vs text encoding.
|
||||
*/
|
||||
hydrate: (
|
||||
dashboardId: string,
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Set / clear the selection for a single variable. Persists to both
|
||||
* LocalStorage and URL.
|
||||
*/
|
||||
setSelection: (name: string, selection: VariableSelection | undefined) => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useVariableSelectionStore = create<SelectionStoreState>(
|
||||
(set, get) => ({
|
||||
dashboardId: '',
|
||||
selections: {},
|
||||
|
||||
hydrate: (dashboardId, hints): void => {
|
||||
const fromUrl = readSelectionsFromUrl(hints);
|
||||
const fromStorage = loadSelectionsFromStorage(dashboardId);
|
||||
// URL wins over LocalStorage (shareable links override personal
|
||||
// preferences).
|
||||
const merged: SelectionsByName = { ...fromStorage, ...fromUrl };
|
||||
set({ dashboardId, selections: merged });
|
||||
},
|
||||
|
||||
setSelection: (name, selection): void => {
|
||||
const { dashboardId, selections } = get();
|
||||
const next: SelectionsByName = { ...selections };
|
||||
if (selection === undefined) {
|
||||
delete next[name];
|
||||
} else {
|
||||
next[name] = selection;
|
||||
}
|
||||
set({ selections: next });
|
||||
saveSelectionsToStorage(dashboardId, next);
|
||||
writeSelectionsToUrl(next);
|
||||
},
|
||||
|
||||
reset: (): void => {
|
||||
set({ dashboardId: '', selections: {} });
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A single variable's selected value.
|
||||
*
|
||||
* - `kind: 'list'` is used for QUERY / CUSTOM / DYNAMIC list variables.
|
||||
* - `allSelected: true` represents the user picking "ALL"; `values` is
|
||||
* ignored in that case.
|
||||
* - `values` is an array even for single-select to keep the shape uniform;
|
||||
* single-select uses index 0.
|
||||
* - `kind: 'text'` is the TextVariable case: one freeform string.
|
||||
*/
|
||||
export type VariableSelection =
|
||||
| { kind: 'list'; values: string[]; allSelected: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
/**
|
||||
* Map of `variable name` → selection. Per dashboard, in memory + persisted.
|
||||
*/
|
||||
export type SelectionsByName = Record<string, VariableSelection | undefined>;
|
||||
|
||||
export const ALL_SENTINEL = '__ALL__';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName, type VariableSelection } from './types';
|
||||
|
||||
const URL_PREFIX = 'var-';
|
||||
|
||||
/**
|
||||
* Encodes a single selection into a URL-safe string. Compact format:
|
||||
* - text variable → the freeform string
|
||||
* - list (ALL) → "__ALL__"
|
||||
* - list (single) → "value"
|
||||
* - list (multi) → "v1,v2,v3"
|
||||
*/
|
||||
function encodeSelection(sel: VariableSelection): string {
|
||||
if (sel.kind === 'text') return sel.value;
|
||||
if (sel.allSelected) return ALL_SENTINEL;
|
||||
return sel.values.join(',');
|
||||
}
|
||||
|
||||
function decodeSelection(
|
||||
raw: string,
|
||||
hint: 'list' | 'text',
|
||||
): VariableSelection {
|
||||
if (hint === 'text') return { kind: 'text', value: raw };
|
||||
if (raw === ALL_SENTINEL) {
|
||||
return { kind: 'list', values: [], allSelected: true };
|
||||
}
|
||||
const values = raw ? raw.split(',') : [];
|
||||
return { kind: 'list', values, allSelected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `var-<name>=<encoded>` params off the current location.
|
||||
* `hints` tells us each variable's kind (list vs text) for decoding.
|
||||
*/
|
||||
export function readSelectionsFromUrl(
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
): SelectionsByName {
|
||||
const out: SelectionsByName = {};
|
||||
if (typeof window === 'undefined') return out;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.forEach((value, key) => {
|
||||
if (!key.startsWith(URL_PREFIX)) return;
|
||||
const name = key.slice(URL_PREFIX.length);
|
||||
const hint = hints[name];
|
||||
if (!hint) return;
|
||||
out[name] = decodeSelection(value, hint);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current selections into the URL, replacing any previous
|
||||
* `var-*` params. Uses `replaceState` so it doesn't pollute history.
|
||||
*/
|
||||
export function writeSelectionsToUrl(selections: SelectionsByName): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Strip existing var-* params
|
||||
const keysToDelete: string[] = [];
|
||||
params.forEach((_, key) => {
|
||||
if (key.startsWith(URL_PREFIX)) keysToDelete.push(key);
|
||||
});
|
||||
keysToDelete.forEach((k) => params.delete(k));
|
||||
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) return;
|
||||
params.set(`${URL_PREFIX}${name}`, encodeSelection(sel));
|
||||
});
|
||||
|
||||
const search = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextUrl);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName } from './state/types';
|
||||
|
||||
/**
|
||||
* Replaces `$varname` references in a string with the current selection.
|
||||
*
|
||||
* - text selection → the freeform string
|
||||
* - list, allSelected → ALL_SENTINEL (callers decide whether to expand to
|
||||
* all known values or to send the literal marker)
|
||||
* - list, single value → that value
|
||||
* - list, multi values → comma-joined; brackets if caller wraps with IN ()
|
||||
*
|
||||
* Variable names match `[a-zA-Z_][a-zA-Z0-9_.]*` so dotted attribute keys
|
||||
* like `$service.name` work. Substitution is non-recursive (we don't expand
|
||||
* `$other` if a value happens to contain another reference).
|
||||
*/
|
||||
const VARIABLE_REF = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g;
|
||||
|
||||
function selectionToString(
|
||||
selection: SelectionsByName[string],
|
||||
): string | null {
|
||||
if (!selection) return null;
|
||||
if (selection.kind === 'text') return selection.value;
|
||||
if (selection.allSelected) return ALL_SENTINEL;
|
||||
if (selection.values.length === 0) return '';
|
||||
return selection.values.join(',');
|
||||
}
|
||||
|
||||
export function substituteVariables(
|
||||
template: string,
|
||||
selections: SelectionsByName,
|
||||
): string {
|
||||
if (!template) return template;
|
||||
return template.replace(VARIABLE_REF, (match, name: string) => {
|
||||
const sel = selections[name];
|
||||
const value = selectionToString(sel);
|
||||
// Leave unresolved references intact so the consumer can decide how to
|
||||
// handle them (better than producing silent partial substitutions).
|
||||
return value === null ? match : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the variable names referenced in a string. Used by the dependency
|
||||
* graph (Phase 5).
|
||||
*/
|
||||
export function referencedVariables(template: string): string[] {
|
||||
if (!template) return [];
|
||||
const out = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(VARIABLE_REF.source, 'g');
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = re.exec(template)) !== null) {
|
||||
out.add(match[1]);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||