mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 16:40:35 +01:00
Compare commits
45 Commits
main
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a5f46f031 | ||
|
|
76f17a88bf | ||
|
|
31393720db | ||
|
|
9905305a10 | ||
|
|
7ba139fc58 | ||
|
|
74a975aa04 | ||
|
|
092c778d8a | ||
|
|
a57bf66a83 | ||
|
|
fc3ab3c566 | ||
|
|
cf82b35406 | ||
|
|
46f473a050 | ||
|
|
e79b639318 | ||
|
|
f98866682c | ||
|
|
b2a160a6be | ||
|
|
da6fafc996 | ||
|
|
9f3e78a390 | ||
|
|
e77b3f2530 | ||
|
|
d1fca96c97 | ||
|
|
e622cc0841 | ||
|
|
d2a9f66191 | ||
|
|
1dab84654e | ||
|
|
92f1071dcf | ||
|
|
de6f7c7271 | ||
|
|
18c3122f96 | ||
|
|
48c8f9ed70 | ||
|
|
564737a851 | ||
|
|
85a1458061 | ||
|
|
7a9adb61be | ||
|
|
97709444cf | ||
|
|
d64336e861 | ||
|
|
7980190327 | ||
|
|
e4b75331b3 | ||
|
|
ed0e11c371 | ||
|
|
57185fa2c4 | ||
|
|
bdd2c0ca13 | ||
|
|
b124c29ff6 | ||
|
|
b66eaaaba7 | ||
|
|
da8c0bab3a | ||
|
|
5875d29d6a | ||
|
|
2932d1c49d | ||
|
|
c8de0838f2 | ||
|
|
39167df22d | ||
|
|
94b407cfcb | ||
|
|
93b0b5065b | ||
|
|
4284b2b6d6 |
69
.github/CODEOWNERS
vendored
69
.github/CODEOWNERS
vendored
@@ -199,72 +199,3 @@ go.mod @therealpandey
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
## Logs
|
||||
/frontend/src/pages/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LogsSettings/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerChart/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerContext/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsExplorerViews/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsFilters/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsSearchFilter/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsAggregate/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsContextList/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsIndexToFields/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsLoading/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogsPanelTable/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogControls/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogDetailedView/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogExplorerQuerySection/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LogLiveTail/ @SigNoz/events-frontend
|
||||
/frontend/src/container/LiveLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/container/EmptyLogsSearch/ @SigNoz/events-frontend
|
||||
/frontend/src/container/NoLogs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/Logs/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogDetail/ @SigNoz/events-frontend
|
||||
/frontend/src/components/LogsFormatOptionsMenu/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/logs/ @SigNoz/events-frontend
|
||||
|
||||
## Logs Pipelines
|
||||
/frontend/src/pages/Pipelines/ @SigNoz/events-frontend
|
||||
/frontend/src/container/PipelinePage/ @SigNoz/events-frontend
|
||||
|
||||
## Traces / Trace Explorer
|
||||
/frontend/src/pages/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesModulePage/ @SigNoz/events-frontend
|
||||
/frontend/src/container/Trace/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesExplorer/ @SigNoz/events-frontend
|
||||
/frontend/src/container/TracesTableComponent/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Funnels
|
||||
/frontend/src/pages/TracesFunnels/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TracesFunnelDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/TracesFunnels/ @SigNoz/events-frontend
|
||||
|
||||
## Trace Details
|
||||
/frontend/src/pages/TraceDetailsV3/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/TraceDetailOldRedirect/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/trace/ @SigNoz/events-frontend
|
||||
|
||||
## Exceptions
|
||||
/frontend/src/pages/AllErrors/ @SigNoz/events-frontend
|
||||
/frontend/src/pages/ErrorDetails/ @SigNoz/events-frontend
|
||||
/frontend/src/container/AllError/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ErrorDetails/ @SigNoz/events-frontend
|
||||
|
||||
## External APIs
|
||||
/frontend/src/pages/ApiMonitoring/ @SigNoz/events-frontend
|
||||
/frontend/src/container/ApiMonitoring/ @SigNoz/events-frontend
|
||||
|
||||
## Messaging Queues
|
||||
/frontend/src/pages/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
@@ -659,29 +659,6 @@ components:
|
||||
refreshToken:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesPostableUser:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
frontendBaseUrl:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
- userRoles
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
- create
|
||||
@@ -10206,7 +10183,7 @@ paths:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint creates an invite for a user
|
||||
operationId: CreateInvite
|
||||
requestBody:
|
||||
@@ -10269,7 +10246,7 @@ paths:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint creates a bulk invite for a user
|
||||
operationId: CreateBulkInvite
|
||||
requestBody:
|
||||
@@ -13110,7 +13087,7 @@ paths:
|
||||
- tracedetail
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint lists all users
|
||||
operationId: ListUsersDeprecated
|
||||
responses:
|
||||
@@ -13203,7 +13180,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint returns the user by id
|
||||
operationId: GetUserDeprecated
|
||||
parameters:
|
||||
@@ -13260,7 +13237,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
put:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint updates the user by id
|
||||
operationId: UpdateUserDeprecated
|
||||
parameters:
|
||||
@@ -13329,7 +13306,7 @@ paths:
|
||||
- users
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint returns the user I belong to
|
||||
operationId: GetMyUserDeprecated
|
||||
responses:
|
||||
@@ -20745,68 +20722,6 @@ paths:
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a user for the organization
|
||||
operationId: CreateUser
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUser'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -98,15 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
Active: aiObservability,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -2258,32 +2258,6 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseUrl?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10833,14 +10807,6 @@ export type ListUsers200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUser201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetUserPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -18,11 +18,9 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
DeleteUserPathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
@@ -171,7 +169,6 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const createInvite = (
|
||||
@@ -233,7 +230,6 @@ export type CreateInviteMutationBody =
|
||||
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create invite
|
||||
*/
|
||||
export const useCreateInvite = <
|
||||
@@ -256,7 +252,6 @@ export const useCreateInvite = <
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const createBulkInvite = (
|
||||
@@ -318,7 +313,6 @@ export type CreateBulkInviteMutationBody =
|
||||
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create bulk invite
|
||||
*/
|
||||
export const useCreateBulkInvite = <
|
||||
@@ -424,7 +418,6 @@ export const useResetPassword = <
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const listUsersDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -470,7 +463,6 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
|
||||
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
|
||||
@@ -494,7 +486,6 @@ export function useListUsersDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List users
|
||||
*/
|
||||
export const invalidateListUsersDeprecated = async (
|
||||
@@ -590,7 +581,6 @@ export const useDeleteUser = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const getUserDeprecated = (
|
||||
@@ -650,7 +640,6 @@ export type GetUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
|
||||
@@ -677,7 +666,6 @@ export function useGetUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get user
|
||||
*/
|
||||
export const invalidateGetUserDeprecated = async (
|
||||
@@ -695,7 +683,6 @@ export const invalidateGetUserDeprecated = async (
|
||||
|
||||
/**
|
||||
* This endpoint updates the user by id
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const updateUserDeprecated = (
|
||||
@@ -768,7 +755,6 @@ export type UpdateUserDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Update user
|
||||
*/
|
||||
export const useUpdateUserDeprecated = <
|
||||
@@ -797,7 +783,6 @@ export const useUpdateUserDeprecated = <
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user I belong to
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const getMyUserDeprecated = (signal?: AbortSignal) => {
|
||||
@@ -843,7 +828,6 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
|
||||
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
|
||||
@@ -867,7 +851,6 @@ export function useGetMyUserDeprecated<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Get my user
|
||||
*/
|
||||
export const invalidateGetMyUserDeprecated = async (
|
||||
@@ -1226,89 +1209,6 @@ export const invalidateListUsers = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a user for the organization
|
||||
* @summary Create user
|
||||
*/
|
||||
export const createUser = (
|
||||
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUser201>({
|
||||
url: `/api/v2/users`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUser'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUser(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody =
|
||||
| BodyType<AuthtypesPostableUserDTO>
|
||||
| undefined;
|
||||
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user
|
||||
*/
|
||||
export const useCreateUser = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the user by id
|
||||
* @summary Get user by user id
|
||||
|
||||
@@ -12,5 +12,4 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
@@ -36,17 +37,16 @@ export default function Legend({
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const containerWidth = legendContainerRef.current.clientWidth;
|
||||
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position]);
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -13,10 +13,11 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
@@ -49,9 +50,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
const createPanel = useCreatePanel();
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -107,8 +107,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
setIsPanelTypeSelectionModalOpen(true);
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
setIsAddingPanel(true);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
@@ -140,6 +140,14 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
<PanelTypeSelectionModal
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={(pluginKind): void => {
|
||||
setIsAddingPanel(false);
|
||||
createPanel({ pluginKind });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isRetryableError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_LABEL,
|
||||
DYNAMIC_SIGNALS,
|
||||
type DynamicSignalOption,
|
||||
signalForApi,
|
||||
} from '../variableFormModel';
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: DynamicSignalOption;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: DynamicSignalOption;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
|
||||
attributeError?: string;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
@@ -37,24 +27,18 @@ function DynamicVariableFields({
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
data: keyData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetFieldKeys({
|
||||
signal: apiSignal,
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// CustomSelect filters the supplied options locally as the user types.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
@@ -65,7 +49,7 @@ function DynamicVariableFields({
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal: apiSignal,
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
@@ -78,60 +62,40 @@ function DynamicVariableFields({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
const errorMessage = error ? (error as Error).message || null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
testId="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
noDataMessage="No fields found"
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{attributeError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
|
||||
import {
|
||||
VARIABLE_SORT_LABEL,
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from '../variableFormModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface ListVariableFieldsProps {
|
||||
model: VariableFormModel;
|
||||
onChange: (patch: Partial<VariableFormModel>) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
defaultValue: string;
|
||||
onDefaultValueChange: (value: string) => void;
|
||||
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
|
||||
showAllOptionField: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
|
||||
* preview, sort, multi-select / ALL toggles and the default-value picker.
|
||||
*/
|
||||
function ListVariableFields({
|
||||
model,
|
||||
onChange,
|
||||
previewValues,
|
||||
previewError,
|
||||
defaultValue,
|
||||
onDefaultValueChange,
|
||||
showAllOptionField,
|
||||
}: ListVariableFieldsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={model.sort}
|
||||
options={VARIABLE_SORTS.map((sort) => ({
|
||||
label: VARIABLE_SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => onChange({ sort: value as VariableSort })}
|
||||
data-testid="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void =>
|
||||
onChange({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
})
|
||||
}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => onChange({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Default Value</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableFields;
|
||||
@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
|
||||
variables: PayloadVariables;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
variables,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
@@ -30,21 +30,20 @@ function QueryVariableFields({
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({ query: queryValue, variables });
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(res.payload.variableValues ?? []);
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
|
||||
const detail = (err as { details?: { error?: string } }).details?.error;
|
||||
const message =
|
||||
detail && detail.includes('Syntax error:')
|
||||
? 'Please make sure query is valid and dependent variables are selected'
|
||||
: detail || (err as Error).message || 'Failed to run query';
|
||||
onError(message);
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
|
||||
@@ -5,8 +5,22 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -28,12 +42,6 @@
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -51,7 +59,7 @@
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
@@ -70,89 +78,48 @@
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type — Tabs root composing the picker row + per-type body panels. */
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Picker row (label left, tabs right); the bottom divider separates type from
|
||||
config. Single line — the tab row scrolls (never wraps) when narrow. */
|
||||
.typePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
|
||||
.typePanel {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.typeContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
padding, segments divided by 1px borders, active segment filled + bold. */
|
||||
.typeTabs {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.typeTab {
|
||||
display: inline-flex;
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='active'] {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
// override the Tabs component's default (transparent) active background.
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
@@ -171,7 +138,7 @@
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -187,7 +154,7 @@
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
@@ -241,7 +208,7 @@
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -304,9 +271,13 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
@@ -326,21 +297,14 @@
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,199 +1,350 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableType } from '../variableFormModel';
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import ListVariableFields from './ListVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import { useVariableForm } from './useVariableForm';
|
||||
import VariableTypeTabs from './VariableTypeTabs';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
|
||||
import { VariableFormProps } from '../types';
|
||||
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list. Form
|
||||
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
|
||||
* {@link ListVariableFields}.
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
existingNames,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
} = useVariableForm({ initial, siblings, isNew, onSave });
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
|
||||
// Shared list rows (preview/sort/multi/default) for the list-type variables;
|
||||
// rendered as a sibling inside each list-type panel. Only the active panel
|
||||
// mounts (Tabs unmounts the rest), so reusing one element is safe.
|
||||
const listFields = isListType ? (
|
||||
<ListVariableFields
|
||||
model={model}
|
||||
onChange={set}
|
||||
previewValues={previewValues}
|
||||
previewError={previewError}
|
||||
defaultValue={defaultValue}
|
||||
onDefaultValueChange={setDefaultValue}
|
||||
showAllOptionField={showAllOptionField}
|
||||
/>
|
||||
) : null;
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<BackToAllVariables onClose={onClose} />
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<VariableInfoForm
|
||||
title={model.name}
|
||||
description={model.description}
|
||||
onTitleChange={onNameChange}
|
||||
onDescriptionChange={(value): void => set({ description: value })}
|
||||
visibleNameError={visibleNameError}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TabsRoot
|
||||
className={styles.typeSection}
|
||||
value={model.type}
|
||||
onValueChange={(next): void => selectType(next as VariableType)}
|
||||
>
|
||||
<VariableTypeTabs />
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="DYNAMIC" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={onDynamicChange}
|
||||
onPreview={setRawPreview}
|
||||
attributeError={attributeError}
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="QUERY" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
variables={payloadVariables}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setRawPreview}
|
||||
onError={setPreviewError}
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="CUSTOM" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="TEXT" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
|
||||
{cycleError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{cycleError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError || !!attributeError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<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} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational trigger row for the variable-type tabs (label + segmented
|
||||
* triggers). Must render inside a `TabsRoot`, which owns the active state and
|
||||
* change handling; the matching `TabsContent` panels are siblings in the root.
|
||||
*/
|
||||
function VariableTypeTabs(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.typePicker}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<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} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.typeTabsScroll}>
|
||||
<TabsList variant="secondary" className={styles.typeTabs}>
|
||||
<TabsTrigger
|
||||
value="DYNAMIC"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
<Pyramid size={14} />
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="TEXT"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
<ClipboardType size={14} />
|
||||
Textbox
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="CUSTOM"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
<LayoutList size={14} />
|
||||
Custom
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="QUERY"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
<DatabaseZap size={14} />
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
<span
|
||||
className={styles.betaTag}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<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} />}
|
||||
/>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeTabs;
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { detectVariableCycle } from '../variableDependencies';
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../variableFormModel';
|
||||
import { getAttributeError, getNameError } from './variableValidation';
|
||||
|
||||
// Stable reference so the zustand selector never returns a fresh object (which
|
||||
// would make useSyncExternalStore loop) when this dashboard has no selections.
|
||||
const EMPTY_SELECTIONS: VariableSelectionMap = {};
|
||||
|
||||
interface UseVariableFormArgs {
|
||||
initial: VariableFormModel;
|
||||
siblings: VariableFormModel[];
|
||||
isNew: boolean;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
export interface UseVariableForm {
|
||||
model: VariableFormModel;
|
||||
set: (patch: Partial<VariableFormModel>) => void;
|
||||
onNameChange: (value: string) => void;
|
||||
selectType: (type: VariableType) => void;
|
||||
onCustomChange: (value: string) => void;
|
||||
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
|
||||
setRawPreview: (values: (string | number)[]) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
setPreviewError: (message: string | null) => void;
|
||||
defaultValue: string;
|
||||
setDefaultValue: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
nameError: string | null;
|
||||
attributeError: string | undefined;
|
||||
cycleError: string | null;
|
||||
isListType: boolean;
|
||||
showAllOptionField: boolean;
|
||||
payloadVariables: PayloadVariables;
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
onSave,
|
||||
}: UseVariableFormArgs): UseVariableForm {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
|
||||
// preview re-sorts when Sort changes.
|
||||
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [cycleError, setCycleError] = useState<string | null>(null);
|
||||
// In add mode, mirror the chosen attribute into the name until the user types.
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
readDefaultValue(initial),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
setCycleError(null);
|
||||
setNameTouched(false);
|
||||
setDefaultValue(readDefaultValue(initial));
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const previewValues = useMemo(
|
||||
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
|
||||
[rawPreview, model.sort],
|
||||
);
|
||||
|
||||
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
|
||||
|
||||
const existingDynamicAttributes = useMemo(
|
||||
() =>
|
||||
siblings
|
||||
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
|
||||
.map((v) => v.dynamicAttribute),
|
||||
[siblings],
|
||||
);
|
||||
|
||||
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(
|
||||
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
|
||||
);
|
||||
const payloadVariables = useMemo<PayloadVariables>(() => {
|
||||
const out: PayloadVariables = {};
|
||||
siblings.forEach((v) => {
|
||||
if (v.name) {
|
||||
out[v.name] = selections[v.name]?.value ?? null;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [siblings, selections]);
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames, initial.name);
|
||||
// Surface the message only once the field is dirty; Save stays disabled regardless.
|
||||
const visibleNameError = nameTouched ? nameError : null;
|
||||
const attributeError = getAttributeError(model, existingDynamicAttributes);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const onNameChange = (value: string): void => {
|
||||
setNameTouched(true);
|
||||
set({ name: value });
|
||||
};
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setRawPreview(commaValuesParser(value));
|
||||
};
|
||||
|
||||
// In add mode, mirror the selected attribute into the name until the user
|
||||
// edits the name themselves (matches the V1 dynamic-variable behaviour).
|
||||
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
|
||||
if (isNew && !nameTouched && patch.dynamicAttribute) {
|
||||
set({ ...patch, name: patch.dynamicAttribute });
|
||||
} else {
|
||||
set(patch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
const next: VariableFormModel = {
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
};
|
||||
|
||||
const cycle = detectVariableCycle([...siblings, next]);
|
||||
if (cycle) {
|
||||
setCycleError(
|
||||
`Cannot save: circular dependency detected between variables: ${cycle.join(
|
||||
' → ',
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCycleError(null);
|
||||
onSave(next);
|
||||
};
|
||||
|
||||
return {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { VariableFormModel } from '../variableFormModel';
|
||||
|
||||
/**
|
||||
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
|
||||
* set includes self, but keeping your own (original) name is always allowed.
|
||||
*/
|
||||
export function getNameError(
|
||||
name: string,
|
||||
existingNames: string[],
|
||||
originalName: string,
|
||||
): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (name !== originalName && existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
|
||||
export function getAttributeError(
|
||||
model: VariableFormModel,
|
||||
existingDynamicAttributes: string[],
|
||||
): string | undefined {
|
||||
if (
|
||||
model.type === 'DYNAMIC' &&
|
||||
model.dynamicAttribute &&
|
||||
existingDynamicAttributes.includes(model.dynamicAttribute)
|
||||
) {
|
||||
return 'A variable with this attribute key already exists';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariableRowProps {
|
||||
variable: VariableFormModel;
|
||||
index: number;
|
||||
canEdit: boolean;
|
||||
/** True when this row's delete is awaiting inline confirmation. */
|
||||
isConfirmingDelete: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
}
|
||||
|
||||
/** A single draggable variable row (drag handle + meta + inline actions). */
|
||||
function VariableRow({
|
||||
variable,
|
||||
index,
|
||||
canEdit,
|
||||
isConfirmingDelete,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: variable.name });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.row}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
{canEdit ? (
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
className={styles.dragHandle}
|
||||
aria-label="Reorder variable"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -2,11 +2,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,14 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,15 +62,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import VariableRow from './VariableRow';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
@@ -37,48 +41,98 @@ function VariablesList({
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = variables.findIndex((v) => v.name === active.id);
|
||||
const to = variables.findIndex((v) => v.name === over.id);
|
||||
if (from !== -1 && to !== -1) {
|
||||
onMove(from, to);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
/>
|
||||
))}
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
const AddVariableButton = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddVariableButton;
|
||||
@@ -1,11 +0,0 @@
|
||||
.backToAllVariables {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l3-border);
|
||||
}
|
||||
|
||||
.backToAllVariablesButton {
|
||||
--button-font-size: 14px;
|
||||
--button-padding: var(--spacing-5) var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import styles from './BackToAllVariables.module.scss';
|
||||
import { VariableFormProps } from '../../types';
|
||||
|
||||
const BackToAllVariables = ({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: VariableFormProps['onClose'];
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.backToAllVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.backToAllVariablesButton}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
size="md"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToAllVariables;
|
||||
@@ -1,25 +0,0 @@
|
||||
.noVariablesCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.noVariablesCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.noVariablesTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.noVariablesInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddVariableButton from '../AddVariableButton';
|
||||
import { EditingState } from '../../types';
|
||||
import styles from './NoVariables.module.scss';
|
||||
|
||||
const NoVariablesCard = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.noVariablesCard}>
|
||||
<div className={styles.noVariablesCopy}>
|
||||
<Typography.Text className={styles.noVariablesTitle}>
|
||||
No variables yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.noVariablesInfo}>
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoVariablesCard;
|
||||
@@ -1,25 +0,0 @@
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.variableNameInput {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
}
|
||||
|
||||
function VariableInfoForm({
|
||||
title,
|
||||
description,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
visibleNameError,
|
||||
}: VariableInfoFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Name</Typography>
|
||||
|
||||
<Input
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
{visibleNameError ? (
|
||||
<Typography.Text className={variableFormStyles.errorText}>
|
||||
<sup>*</sup>
|
||||
{visibleNameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Description</Typography>
|
||||
<AntdInput.TextArea
|
||||
className={styles.descriptionTextArea}
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
data-testid="dashboard-desc"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableInfoForm;
|
||||
@@ -1,75 +1,75 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableFormModel';
|
||||
} from './variableModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
[dashboard.spec.variables],
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const [variables, setVariables] =
|
||||
useState<VariableFormModel[]>(initialFormModels);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialFormModels);
|
||||
setVariables(initialModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
return isEditing.type === 'new'
|
||||
return editing.index === null
|
||||
? emptyVariableFormModel()
|
||||
: variables[isEditing.index];
|
||||
}, [isEditing, variables]);
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
|
||||
const siblings = useMemo(() => {
|
||||
const self = isEditing?.type === 'edit' ? isEditing.index : null;
|
||||
return variables.filter((_, i) => i !== self);
|
||||
}, [variables, isEditing]);
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (Formmodel: VariableFormModel): void => {
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(Formmodel);
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = Formmodel;
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
}
|
||||
setIsEditing(null);
|
||||
setEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingFormModel}
|
||||
siblings={siblings}
|
||||
isNew={isEditing?.type === 'new'}
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsEditing(null)}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
@@ -103,25 +103,42 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
export type EditingState =
|
||||
| { type: 'new' }
|
||||
| { type: 'edit'; index: number }
|
||||
| null;
|
||||
|
||||
export interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** The other variables (excluding this one), for uniqueness & cycle checks. */
|
||||
siblings: VariableFormModel[];
|
||||
/** True when adding a new variable (enables auto-naming from the attribute). */
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
@@ -13,24 +14,21 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type DynamicSignalOption,
|
||||
emptyVariableFormModel,
|
||||
signalForApi,
|
||||
VARIABLE_SORT_DISABLED,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableFormModel';
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec.display;
|
||||
const display = dto.spec?.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
// TODO
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
};
|
||||
@@ -52,7 +50,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
@@ -69,9 +67,7 @@ export function dtoToFormModel(
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
// An omitted wire signal means "all telemetry".
|
||||
dynamicSignal:
|
||||
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
@@ -99,7 +95,7 @@ function buildPlugin(
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: signalForApi(model.dynamicSignal),
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
@@ -118,6 +114,7 @@ export function formModelToDto(
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
@@ -138,10 +135,7 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
allowAllValue: model.showAllOption,
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
@@ -155,3 +149,5 @@ export function variableTypeOf(
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
|
||||
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/**
|
||||
* Detects a circular reference among QUERY variables (a query referencing
|
||||
* another that, transitively, references it back). Reuses the V1 dependency
|
||||
* graph helpers, which key off `name` / `type` / `queryValue` only.
|
||||
*
|
||||
* Returns the names forming the cycle, or `null` when the set is acyclic.
|
||||
*/
|
||||
export function detectVariableCycle(
|
||||
variables: VariableFormModel[],
|
||||
): string[] | null {
|
||||
const asDbVariables = variables
|
||||
.filter((variable) => variable.name)
|
||||
.map(
|
||||
(variable) =>
|
||||
({
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
queryValue: variable.queryValue,
|
||||
}) as IDashboardVariable,
|
||||
);
|
||||
|
||||
const { hasCycle, cycleNodes } = buildDependencyGraph(
|
||||
buildDependencies(asDbVariables),
|
||||
);
|
||||
|
||||
return hasCycle ? (cycleNodes ?? []) : null;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortBy } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* The four variable types the editor exposes. No generated enum exists for this
|
||||
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
|
||||
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
|
||||
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
|
||||
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
|
||||
* ever exposes a single variable-kind type.
|
||||
*/
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
export type TelemetrySignal = TelemetrytypesSignalDTO;
|
||||
|
||||
/**
|
||||
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
|
||||
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
|
||||
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
|
||||
*/
|
||||
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
|
||||
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
|
||||
|
||||
/**
|
||||
* Sort order for list-variable values. The wire (Perses) validates `sort`
|
||||
* against a fixed method set. There is no generated TS enum for it
|
||||
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
|
||||
* so we mirror the Perses `Sort` tokens here.
|
||||
*/
|
||||
export const VARIABLE_SORT = {
|
||||
DISABLED: 'none',
|
||||
ASC: 'alphabetical-asc',
|
||||
DESC: 'alphabetical-desc',
|
||||
NUMERICAL_ASC: 'numerical-asc',
|
||||
NUMERICAL_DESC: 'numerical-desc',
|
||||
CI_ASC: 'alphabetical-ci-asc',
|
||||
CI_DESC: 'alphabetical-ci-desc',
|
||||
} as const;
|
||||
|
||||
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
|
||||
|
||||
/** Persisted "no sort" value (Perses `none`). */
|
||||
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = [
|
||||
VARIABLE_SORT.DISABLED,
|
||||
VARIABLE_SORT.ASC,
|
||||
VARIABLE_SORT.DESC,
|
||||
VARIABLE_SORT.NUMERICAL_ASC,
|
||||
VARIABLE_SORT.NUMERICAL_DESC,
|
||||
VARIABLE_SORT.CI_ASC,
|
||||
VARIABLE_SORT.CI_DESC,
|
||||
];
|
||||
|
||||
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
|
||||
[VARIABLE_SORT.DISABLED]: 'Disabled',
|
||||
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
|
||||
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
|
||||
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
|
||||
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
|
||||
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
|
||||
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
|
||||
};
|
||||
|
||||
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
];
|
||||
|
||||
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
|
||||
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
|
||||
[TelemetrytypesSignalDTO.traces]: 'Traces',
|
||||
[TelemetrytypesSignalDTO.logs]: 'Logs',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
|
||||
};
|
||||
|
||||
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
|
||||
export function signalForApi(
|
||||
signal: DynamicSignalOption,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
switch (sort) {
|
||||
case VARIABLE_SORT.ASC:
|
||||
return sortBy(values);
|
||||
case VARIABLE_SORT.DESC:
|
||||
return sortBy(values).reverse();
|
||||
case VARIABLE_SORT.NUMERICAL_ASC:
|
||||
return sortBy(values, (value) => Number(value));
|
||||
case VARIABLE_SORT.NUMERICAL_DESC:
|
||||
return sortBy(values, (value) => Number(value)).reverse();
|
||||
case VARIABLE_SORT.CI_ASC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase());
|
||||
case VARIABLE_SORT.CI_DESC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: VARIABLE_SORT_DISABLED,
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface ConfigPaneProps {
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
@@ -36,6 +38,7 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
@@ -95,6 +98,8 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Panel's current datasource — drives the disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). Types whose
|
||||
* supported signals exclude the panel's current datasource are disabled (V1 parity —
|
||||
* e.g. List needs logs/traces, not metrics). The datasource is unknown for
|
||||
* PromQL/ClickHouse queries, in which case no type is disabled.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map((type) => {
|
||||
const definition = getPanelDefinition(type.pluginKind as PanelKind);
|
||||
return {
|
||||
value: type.pluginKind,
|
||||
label: type.label,
|
||||
icon: type.icon,
|
||||
disabled:
|
||||
!!signal && !!definition && !definition.supportedSignals.includes(signal),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value as PanelKind)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSwitcher;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current datasource', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
const disabled = Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabled).toContain('List');
|
||||
expect(disabled).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the datasource is unknown', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
@@ -23,6 +24,9 @@ interface SectionSlotProps {
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Current panel kind + switch handler, for the visualization section's type switcher. */
|
||||
panelKind: PanelKind;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,8 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -60,7 +66,12 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === 'visualization'}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -69,6 +80,8 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
@@ -7,7 +8,9 @@ import styles from './ConfigSelect.module.scss';
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
/** A `SegmentIconName` string (resolved to a glyph), or an arbitrary icon node. */
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
@@ -40,9 +43,14 @@ function ConfigSelect({
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{typeof item.icon === 'string' ? (
|
||||
<SegmentIcon name={item.icon as SegmentIconName} />
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -157,6 +157,10 @@ export interface ErasedSectionDescriptor {
|
||||
// The panel's telemetry signal; read by editors that fetch field-key
|
||||
// suggestions scoped to it (List column picker).
|
||||
signal?: unknown;
|
||||
// Current panel kind + switch handler; read by the visualization section's
|
||||
// type switcher.
|
||||
panelKind?: unknown;
|
||||
onChangePanelKind?: unknown;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
|
||||
@@ -9,8 +9,8 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
|
||||
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard). No
|
||||
// `controls` is passed, exercising the default `label` variant.
|
||||
// Stateful harness for flows that depend on the value updating (add/discard);
|
||||
// omits `controls` to exercise the default `label` variant.
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
return (
|
||||
@@ -33,7 +33,6 @@ describe('ThresholdsSection', () => {
|
||||
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
// The editable fields are hidden until the row is edited.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -54,6 +53,21 @@ describe('ThresholdsSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists an empty-string label when none is provided', () => {
|
||||
const onChange = jest.fn();
|
||||
// Label absent (e.g. a pre-existing spec); spec requires a string, so save
|
||||
// must send '' not undefined.
|
||||
const noLabel = [{ value: 50, color: '#F1575F' }] as AnyThreshold[];
|
||||
render(<ThresholdsSection value={noLabel} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
fireEvent.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ value: 50, color: '#F1575F', label: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
@@ -65,7 +79,6 @@ describe('ThresholdsSection', () => {
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
});
|
||||
@@ -83,11 +96,10 @@ describe('ThresholdsSection', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
// New row opens in edit mode.
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,10 +23,7 @@ interface LabelThresholdRowProps {
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
|
||||
* form is color, value, unit, label.
|
||||
*/
|
||||
/** Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. */
|
||||
function LabelThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
@@ -58,7 +55,7 @@ function LabelThresholdRow({
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onSave={(): void => onSave({ ...draft, label: draft.label ?? '' })}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { PanelKind } from '../../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
type VisualizationSectionProps = SectionEditorProps<'visualization'> & {
|
||||
/** Current panel kind + switch handler, forwarded by SectionSlot for the type switcher. */
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Panel's datasource, forwarded by SectionSlot — scopes the switcher's disabled types. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
|
||||
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
|
||||
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
|
||||
* writes — the visualization fields its spec actually supports.
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.switchPanelKind && panelKind && onChangePanelKind && (
|
||||
<PanelTypeSwitcher
|
||||
panelKind={panelKind}
|
||||
signal={signal}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.s
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// The type switcher resolves each kind's supported signals; stub it so the test
|
||||
// doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
@@ -17,7 +25,12 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -35,7 +48,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -56,7 +72,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -74,7 +93,10 @@ describe('VisualizationSection', () => {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
stacking: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -92,7 +114,10 @@ describe('VisualizationSection', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
controls={{
|
||||
switchPanelKind: true,
|
||||
fillSpans: true,
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -101,4 +126,43 @@ describe('VisualizationSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
|
||||
const onChangePanelKind = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true }}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
it('hides the type switcher when switchPanelKind is not set', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{
|
||||
switchPanelKind: false,
|
||||
timePreference: true,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChangePanelKind={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-switcher'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type {
|
||||
DashboardtypesListPanelSpecDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
import {
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
/**
|
||||
* The field-key suggestions API and the default-column constants carry extra
|
||||
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
|
||||
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
|
||||
* `selectFields` only contain backend-known keys.
|
||||
* Reduce each column to the field-key DTO shape: the suggestions API and default
|
||||
* constants carry extra runtime fields (e.g. `isIndexed`) the save contract rejects.
|
||||
*/
|
||||
function toFieldKeyDTO(
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO,
|
||||
@@ -31,10 +34,26 @@ export function sanitizeSelectFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
|
||||
* run only for the List panel, so it's narrowed to the List variant with a single
|
||||
* localized cast at the boundary.
|
||||
* logs/traces List-column defaults (V1 parity), sanitized to the field-key DTO.
|
||||
*/
|
||||
export function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these List-only
|
||||
// helpers narrow to the List variant via a single localized cast at the boundary.
|
||||
export function readSelectFields(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface PlotTagProps {
|
||||
/** Authoring mode of the panel's query; undefined when no query exists yet. */
|
||||
queryType: EQueryType | undefined;
|
||||
panelType: PANEL_TYPES;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
|
||||
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
|
||||
* query exists, where the mode is irrelevant.
|
||||
*/
|
||||
function PlotTag({
|
||||
queryType,
|
||||
panelType,
|
||||
className,
|
||||
}: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="panel-editor-plot-tag">
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={queryType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlotTag;
|
||||
@@ -43,8 +43,10 @@
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
// Header stacks above the body, flush to the border — mirrors the dashboard
|
||||
// grid's `.panel` so the preview reads as the real panel chrome.
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
@@ -57,3 +59,7 @@
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dateTimeSelector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; undefined when the kind is unsupported. */
|
||||
panelDef: RenderablePanelDefinition | undefined;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
/** Background refresh in flight — drives the header's subtle refetch spinner. */
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the query (drives PanelBody's error-state retry). */
|
||||
refetch: () => void;
|
||||
@@ -30,50 +34,70 @@ interface PreviewPaneProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode={DASHBOARD_EDIT}` differs), so the preview
|
||||
* is the production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDef,
|
||||
panelDefinition,
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{panelDef ? (
|
||||
<PanelBody
|
||||
panelDefinition={panelDef}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
)}
|
||||
<PanelHeader
|
||||
name={panel.spec.display.name}
|
||||
description={panel.spec.display?.description}
|
||||
panelId={panelId}
|
||||
panelKind={panel.spec.plugin.kind}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from '../PlotTag';
|
||||
|
||||
describe('PlotTag', () => {
|
||||
it('renders the resolved query mode', () => {
|
||||
render(
|
||||
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query yet', () => {
|
||||
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for list panels (query mode is irrelevant)', () => {
|
||||
render(
|
||||
<PlotTag
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import { defaultColumnsForSignal } from '../ListColumnsEditor/selectFields';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/selectFields', () => ({
|
||||
defaultColumnsForSignal: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
const mockDefaultColumnsForSignal =
|
||||
defaultColumnsForSignal as unknown as jest.Mock;
|
||||
|
||||
function specWith(pluginSpec: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind: 'signoz/TablePanel', spec: pluginSpec },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('getSwitchedPluginSpec', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDefaultColumnsForSignal.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('carries only unit + decimalPrecision when the new kind has a formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'formatting', controls: { unit: true, decimals: true } }],
|
||||
});
|
||||
const old = specWith({
|
||||
formatting: { unit: 'ms', decimalPrecision: 2, columnUnits: { A: 'bytes' } },
|
||||
axes: { logScale: true },
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(old, 'signoz/TimeSeriesPanel');
|
||||
|
||||
expect(result.formatting).toStrictEqual({ unit: 'ms', decimalPrecision: 2 });
|
||||
// Type-specific config from the old kind is dropped.
|
||||
expect((result as { axes?: unknown }).axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not carry formatting when the new kind has no formatting section', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
const old = specWith({ formatting: { unit: 'ms' } });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
old,
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(result.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds List columns from the signal when switching into a List', () => {
|
||||
const columns = [{ name: 'body' }];
|
||||
mockDefaultColumnsForSignal.mockReturnValue(columns);
|
||||
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
|
||||
|
||||
const result = getSwitchedPluginSpec(
|
||||
specWith({}),
|
||||
'signoz/ListPanel',
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
|
||||
expect(mockDefaultColumnsForSignal).toHaveBeenCalledWith(
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
);
|
||||
expect(result.selectFields).toBe(columns);
|
||||
});
|
||||
|
||||
it('includes the kind section defaults (e.g. legend position)', () => {
|
||||
mockGetPanelDefinition.mockReturnValue({
|
||||
sections: [{ kind: 'legend', controls: { position: true } }],
|
||||
});
|
||||
|
||||
const result = getSwitchedPluginSpec(specWith({}), 'signoz/PieChartPanel');
|
||||
|
||||
expect(result.legend?.position).toBe('bottom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../newPanelRoute';
|
||||
|
||||
describe('newPanelRoute', () => {
|
||||
it('round-trips kind + layoutIndex through the new-panel search', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel', 2);
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
|
||||
expect(parseNewPanelLayoutIndex(search)).toBe(2);
|
||||
});
|
||||
|
||||
it('omits layoutIndex when not provided', () => {
|
||||
const search = newPanelSearch('signoz/TimeSeriesPanel');
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null for an existing panel id (not the new sentinel)', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel');
|
||||
expect(parseNewPanelKind('a1b2c3d4-uuid', search)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the kind param is missing or unknown', () => {
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, '')).toBeNull();
|
||||
expect(
|
||||
parseNewPanelKind(NEW_PANEL_ID, '?panelKind=NotARealPanel'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
type DefaultPluginSpec,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
|
||||
import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* Plugin spec produced on a first-time switch to a new kind. A partial cross-section
|
||||
* of the per-kind spec union; the caller assigns it to `plugin.spec` (typed `unknown`)
|
||||
* at the boundary.
|
||||
*/
|
||||
export interface SwitchedPluginSpec extends DefaultPluginSpec {
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
|
||||
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
|
||||
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
|
||||
* List seeds the current signal's default columns so the columns control isn't empty.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
export function getSwitchedPluginSpec(
|
||||
oldSpec: DashboardtypesPanelSpecDTO,
|
||||
newKind: PanelKind,
|
||||
signal?: TelemetrytypesSignalDTO,
|
||||
): SwitchedPluginSpec {
|
||||
const sections = getPanelDefinition(newKind)?.sections ?? [];
|
||||
const result: SwitchedPluginSpec = buildDefaultPluginSpec(sections);
|
||||
|
||||
if (sections.some((section) => section.kind === 'formatting')) {
|
||||
const oldFormatting = (
|
||||
oldSpec.plugin.spec as {
|
||||
formatting?: PanelFormattingSlice;
|
||||
}
|
||||
).formatting;
|
||||
const carried: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'> = {
|
||||
...(oldFormatting?.unit !== undefined && { unit: oldFormatting.unit }),
|
||||
...(oldFormatting?.decimalPrecision !== undefined && {
|
||||
decimalPrecision: oldFormatting.decimalPrecision,
|
||||
}),
|
||||
};
|
||||
if (Object.keys(carried).length > 0) {
|
||||
result.formatting = carried;
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.some((section) => section.kind === 'columns') && signal) {
|
||||
const columns = defaultColumnsForSignal(signal);
|
||||
if (columns.length > 0) {
|
||||
result.selectFields = columns;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
PANEL_TYPE_TO_QUERY_TYPES: {
|
||||
graph: ['builder', 'clickhouse', 'promql'],
|
||||
table: ['builder', 'clickhouse'],
|
||||
list: ['builder'],
|
||||
value: ['builder', 'clickhouse', 'promql'],
|
||||
bar: ['builder', 'clickhouse', 'promql'],
|
||||
pie: ['builder', 'clickhouse'],
|
||||
histogram: ['builder', 'clickhouse', 'promql'],
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../getSwitchedPluginSpec', () => ({
|
||||
getSwitchedPluginSpec: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
getBuilderQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
|
||||
// Opaque sentinels — the leaf utilities are mocked, so only identity matters.
|
||||
const TABLE_PLUGIN_SPEC = { table: true } as unknown;
|
||||
const TABLE_QUERIES = [{ id: 'table-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const LIST_PLUGIN_SPEC = { list: true } as unknown;
|
||||
const LIST_QUERIES = [{ id: 'list-q' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const TRANSFORMED = {
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
const CONVERTED = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SWITCHED_SPEC = { switched: true } as unknown;
|
||||
|
||||
function makeSpec(
|
||||
kind: string,
|
||||
pluginSpec: unknown,
|
||||
queries: NonNullable<DashboardtypesPanelSpecDTO['queries']>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Panel' },
|
||||
plugin: { kind, spec: pluginSpec },
|
||||
queries,
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
const tableSpec = makeSpec(
|
||||
'signoz/TablePanel',
|
||||
TABLE_PLUGIN_SPEC,
|
||||
TABLE_QUERIES,
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
mockToPerses.mockReturnValue(CONVERTED);
|
||||
mockGetSwitchedPluginSpec.mockReturnValue(SWITCHED_SPEC);
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
expect(state.redirectWithQueryBuilderData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
expect(setSpec).toHaveBeenCalledTimes(1);
|
||||
const next = setSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(next.plugin.kind).toBe('signoz/ListPanel');
|
||||
expect(next.plugin.spec).toBe(SWITCHED_SPEC);
|
||||
expect(next.queries).toBe(CONVERTED);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(TRANSFORMED);
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// List allows only Query Builder, so the promql query is coerced to 'builder'.
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table for List (stashes Table in its pristine state).
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// Parent re-renders as a List panel; the builder now holds the List query.
|
||||
state = builderState(listQuery);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
|
||||
// Switch back to Table → restored from the stash, not re-transformed.
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restored.plugin.kind).toBe('signoz/TablePanel');
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(TABLE_QUERIES);
|
||||
expect(state.redirectWithQueryBuilderData).toHaveBeenCalledWith(tableQuery);
|
||||
// The restore path must not run the query transform again.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { sanitizeSelectFields } from '../../ListColumnsEditor/selectFields';
|
||||
import { useSwitchColumnsOnSignalChange } from '../useSwitchColumnsOnSignalChange';
|
||||
|
||||
// The hook applies the datasource defaults reduced to the field-key DTO (the V1
|
||||
// constants carry extra keys like `isIndexed`); assertions mirror that.
|
||||
// V1 constants carry extra keys (e.g. `isIndexed`); the hook reduces them to the
|
||||
// field-key DTO, so assertions sanitize the same way.
|
||||
const expectedLogs = sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
@@ -73,6 +73,45 @@ describe('useSwitchColumnsOnSignalChange', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the original columns on logs → traces → logs', () => {
|
||||
// Customized logs selection, not the timestamp/body defaults.
|
||||
const original = [
|
||||
{ name: 'timestamp' },
|
||||
{ name: 'body' },
|
||||
{ name: 'response_status_code' },
|
||||
{ name: 'trace_id' },
|
||||
];
|
||||
// Mirror the real parent: persist the spec so the next switch stashes the
|
||||
// columns the previous one applied.
|
||||
let spec = makeSpec(original);
|
||||
const onChangeSpec = jest.fn((next: DashboardtypesPanelSpecDTO) => {
|
||||
spec = next;
|
||||
});
|
||||
const { rerender } = renderWith({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(expectedTraces);
|
||||
|
||||
// Switching back restores the original columns, not the log defaults.
|
||||
rerender({
|
||||
enabled: true,
|
||||
signal: TelemetrytypesSignalDTO.logs,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
});
|
||||
expect(selectFieldsOf(spec)).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('switches to the log defaults when going traces → logs', () => {
|
||||
const onChangeSpec = jest.fn();
|
||||
const spec = makeSpec([{ name: 'service.name' }]);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getIsQueryModified } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -19,14 +20,24 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Re-fetch the preview when the query is unchanged (Stage & Run on a no-op). */
|
||||
refetch: () => void;
|
||||
/**
|
||||
* Serialize the live query on save even when unchanged. Set for a new panel,
|
||||
* whose seed query is the builder default (not a real saved query).
|
||||
*/
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/**
|
||||
* Datasource to seed a new panel's builder with — the panel kind's first
|
||||
* supported signal (e.g. List supports only logs/traces, not metrics).
|
||||
*/
|
||||
defaultDataSource?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
/** Run the current query (Stage & Run / ⌘↵). */
|
||||
runQuery: () => void;
|
||||
/** True when the live builder query differs from the saved query (compared builder-normalized to avoid re-serialization noise). */
|
||||
/** True when the live builder query differs from the saved query. */
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live query into a spec for saving so unstaged edits persist; returns the spec untouched when unchanged. */
|
||||
/** Bake the live query into a spec so unstaged edits persist; unchanged → spec untouched. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
@@ -34,27 +45,36 @@ interface UsePanelEditorQuerySyncApi {
|
||||
|
||||
/**
|
||||
* Bridges the shared (URL-synced) query builder and the V2 editor draft: seeds the
|
||||
* builder from the saved panel, then commits the active query into `draft.spec.queries`
|
||||
* (what the preview fetches) on a query-type/datasource switch and on Stage & Run.
|
||||
* builder from the saved panel, then commits the active query into
|
||||
* `draft.spec.queries` (what the preview fetches) on a query-type/datasource switch
|
||||
* and on Stage & Run.
|
||||
*/
|
||||
export function usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
defaultDataSource,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
const savedQueries = useMemo(() => draft.spec?.queries ?? [], []);
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() => fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType],
|
||||
() =>
|
||||
savedQueries.length === 0 && defaultDataSource
|
||||
? initialQueriesMap[defaultDataSource]
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, defaultDataSource],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding any
|
||||
// stale URL query from a prior edit — otherwise the QB and preview diverge and the
|
||||
// dirty baseline gets captured from the URL. After mount the URL syncs normally.
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
@@ -64,11 +84,10 @@ export function usePanelEditorQuerySync({
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty check
|
||||
// compares against the SAVED query (`seedQuery`), not the URL-synced staged query,
|
||||
// which can carry stale state across a refresh and make a real switch read as
|
||||
// "unchanged". Unchanged → restore saved queries; changed → commit. Returns whether
|
||||
// the draft changed.
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
@@ -76,7 +95,7 @@ export function usePanelEditorQuerySync({
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so comparing them structurally would falsely dirty the draft.
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
const current = draft.spec?.queries ?? [];
|
||||
if (isEqual(toQueryEnvelopes(next), toQueryEnvelopes(current))) {
|
||||
return false;
|
||||
@@ -93,8 +112,8 @@ export function usePanelEditorQuerySync({
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type or datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is force-reset to.
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSourceSignature = useMemo(
|
||||
() =>
|
||||
(currentQuery.builder?.queryData ?? []).map((q) => q.dataSource).join(','),
|
||||
@@ -119,10 +138,10 @@ export function usePanelEditorQuerySync({
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset). Comparing builder-normalized to
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. Held in state (not a ref) so capture re-triggers `isQueryDirty`;
|
||||
// captured once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
@@ -135,10 +154,10 @@ export function usePanelEditorQuerySync({
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
isQueryDirty
|
||||
isQueryDirty || alwaysSerializeQuery
|
||||
? { ...spec, queries: toPerses(currentQuery, panelType) }
|
||||
: spec,
|
||||
[isQueryDirty, currentQuery, panelType],
|
||||
[isQueryDirty, alwaysSerializeQuery, currentQuery, panelType],
|
||||
);
|
||||
|
||||
return { runQuery, isQueryDirty, buildSaveSpec };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
@@ -7,12 +8,20 @@ import {
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesPanelKindDTO,
|
||||
DashboardtypesPatchOpDTO,
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
@@ -22,34 +31,49 @@ interface UsePanelEditorSaveApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel edits via a single RFC-6902 `add` op that replaces the whole panel
|
||||
* spec at `/spec/panels/{panelId}/spec`, so every config-pane edit is saved (not just
|
||||
* title/description). `add` doubles as create-or-replace, avoiding a separate
|
||||
* existence check.
|
||||
* Persists panel edits for the V2 editor via RFC-6902 JSON Patch. Editing: one
|
||||
* `add` op replaces the whole spec. Creating (`isNew`): mints a fresh id and adds
|
||||
* a grid item in the target section. Persists only on save — cancelling never
|
||||
* touches the dashboard.
|
||||
*/
|
||||
export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
ops = [
|
||||
{
|
||||
op: DashboardtypesPatchOpDTO.add,
|
||||
path: `/spec/panels/${panelId}/spec`,
|
||||
value: spec,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(
|
||||
getGetDashboardV2QueryKey({ id: dashboardId }),
|
||||
);
|
||||
await queryClient.invalidateQueries(dashboardQueryKey);
|
||||
},
|
||||
[dashboardId, panelId, mutateAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
PANEL_TYPE_TO_QUERY_TYPES,
|
||||
type PartialPanelTypes,
|
||||
} from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../getSwitchedPluginSpec';
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: unknown;
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries ?? null,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
|
||||
|
||||
// `plugin` is a discriminated union over `kind`; a dynamically-chosen kind
|
||||
// can't be expressed in the static union, so build the new spec and cast at
|
||||
// this boundary (as `createDefaultPanel` does).
|
||||
const buildSpec = (
|
||||
pluginSpec: unknown,
|
||||
queries: DashboardtypesQueryDTO[] | null,
|
||||
): DashboardtypesPanelSpecDTO =>
|
||||
({
|
||||
...currentSpec,
|
||||
plugin: { ...currentSpec.plugin, kind: newKind, spec: pluginSpec },
|
||||
queries,
|
||||
}) as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new panel disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const supported = PANEL_TYPE_TO_QUERY_TYPES[newPanelType] ?? [];
|
||||
const queryType = supported.includes(query.queryType)
|
||||
? query.queryType
|
||||
: supported[0];
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(transformed, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(transformed);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
interface UseSeedNewListColumnsArgs {
|
||||
/** Gate: a brand-new List panel (the only case that should auto-fill columns). */
|
||||
enabled: boolean;
|
||||
/** Default signal for the new panel — its kind's first supported signal. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a brand-new List panel's columns with its default signal's columns so the
|
||||
* Columns control isn't empty on first open. Runs once and only when empty: an
|
||||
* empty selection is a valid "show all fields" state, so existing panels and
|
||||
* user-cleared selections are never touched.
|
||||
*/
|
||||
export function useSeedNewListColumns({
|
||||
enabled,
|
||||
signal,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedNewListColumnsArgs): void {
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || seededRef.current || !signal) {
|
||||
return;
|
||||
}
|
||||
// Only seed when empty — don't clobber a selection that's already present.
|
||||
if (readSelectFields(spec).length > 0) {
|
||||
return;
|
||||
}
|
||||
seededRef.current = true;
|
||||
onChangeSpec(writeSelectFields(spec, defaultColumnsForSignal(signal)));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
|
||||
import { sanitizeSelectFields } from '../ListColumnsEditor/selectFields';
|
||||
|
||||
/**
|
||||
* The datasource's default List columns (V1 parity), sanitized to the field-key
|
||||
* DTO — the V1 constants carry extra keys (isIndexed) the save contract rejects.
|
||||
* Other signals (metrics) don't produce a list, so they clear the selection.
|
||||
*/
|
||||
function defaultColumnsForSignal(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {
|
||||
return sanitizeSelectFields(
|
||||
defaultLogsSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {
|
||||
return sanitizeSelectFields(
|
||||
defaultTraceSelectedColumns as TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
defaultColumnsForSignal,
|
||||
readSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../ListColumnsEditor/selectFields';
|
||||
|
||||
interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
/** Gate so the switch only runs for the List kind (the only one with columns). */
|
||||
@@ -42,11 +22,10 @@ interface UseSwitchColumnsOnSignalChangeArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the List panel's chosen columns to the new datasource's defaults when
|
||||
* the panel's telemetry signal changes (e.g. logs → traces). V1 kept a separate
|
||||
* field list per datasource; V2 stores a single `selectFields`, so columns picked
|
||||
* for one signal are meaningless after switching — replace them with the new
|
||||
* source's sensible defaults (matching V1's logs/traces list defaults).
|
||||
* Swaps the List panel's columns when the telemetry signal changes. V2 stores a
|
||||
* single `selectFields`, so each signal's columns are stashed and restored on
|
||||
* switch-back; a signal seen for the first time gets the datasource defaults (V1
|
||||
* parity).
|
||||
*/
|
||||
export function useSwitchColumnsOnSignalChange({
|
||||
enabled,
|
||||
@@ -55,28 +34,29 @@ export function useSwitchColumnsOnSignalChange({
|
||||
onChangeSpec,
|
||||
}: UseSwitchColumnsOnSignalChangeArgs): void {
|
||||
const prevSignalRef = useRef(signal);
|
||||
const columnsBySignalRef = useRef<
|
||||
Map<string, TelemetrytypesTelemetryFieldKeyDTO[]>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSignalRef.current;
|
||||
|
||||
if (!enabled || !signal) {
|
||||
return;
|
||||
}
|
||||
// Track only real signals: a transient `undefined` (mid query-edit) must
|
||||
// not become `prev`, or stash/restore would lose a step.
|
||||
prevSignalRef.current = signal;
|
||||
|
||||
if (!enabled) {
|
||||
if (!prev || prev === signal) {
|
||||
return;
|
||||
}
|
||||
// Only an actual switch between two known signals swaps the columns;
|
||||
// transient `undefined` states (mid query-edit) leave the selection intact.
|
||||
if (!prev || !signal || prev === signal) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: {
|
||||
...spec.plugin.spec,
|
||||
selectFields: defaultColumnsForSignal(signal),
|
||||
},
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
|
||||
// Stash the leaving signal's columns; restore the entering one's, or its
|
||||
// datasource defaults the first time it's seen.
|
||||
columnsBySignalRef.current.set(prev, readSelectFields(spec));
|
||||
const restored =
|
||||
columnsBySignalRef.current.get(signal) ?? defaultColumnsForSignal(signal);
|
||||
onChangeSpec(writeSelectFields(spec, restored));
|
||||
}, [enabled, signal, spec, onChangeSpec]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type {
|
||||
import {
|
||||
DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -29,6 +29,8 @@ import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
@@ -39,6 +41,10 @@ interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -46,19 +52,26 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body (rendered full-page by `PanelEditorPage`): a resizable
|
||||
* split with the live preview + query builder on the left and the config pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
* V2 panel editor page body: a resizable split with the live preview + query
|
||||
* builder on the left and the config pane on the right. Owns the draft state and
|
||||
* the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
@@ -79,7 +92,7 @@ function PanelEditorContainer({
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDef = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -91,23 +104,33 @@ function PanelEditorContainer({
|
||||
} = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// Seed the shared query builder from the draft and expose the Stage-&-Run action.
|
||||
// A new panel defaults to its kind's first supported signal (e.g. List → logs);
|
||||
// drives both the seed query's datasource and the seed of its default columns.
|
||||
const defaultDataSource = useMemo(
|
||||
() => panelDefinition?.supportedSignals[0] || TelemetrytypesSignalDTO.metrics,
|
||||
[panelDefinition],
|
||||
);
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
defaultDataSource,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties.
|
||||
const isDirty = isSpecDirty || isQueryDirty;
|
||||
// The List panel edits its columns below the query builder (V1 parity), so the
|
||||
// editor container resolves the committed query's signal once and shares it
|
||||
// with both the columns control and the datasource-switch effect below.
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
const isListPanel = fullKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
@@ -116,18 +139,24 @@ function PanelEditorContainer({
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
|
||||
// When the List panel's datasource changes, swap its columns to the new
|
||||
// source's defaults (V1 kept a per-datasource field list; V2 has one
|
||||
// `selectFields`). Driven by the committed query's signal, so it lives in the
|
||||
// editor container alongside the query sync — ConfigPane stays presentational.
|
||||
// Swap the List panel's columns to the new source's defaults on a datasource
|
||||
// change (V1 kept a per-datasource field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
// A brand-new List panel starts with no columns; seed the default signal's
|
||||
// columns once so the Columns control isn't empty on first open (V1 parity).
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: defaultDataSource,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
// Drag-to-zoom on the preview chart updates the URL-synced time window, as on
|
||||
// the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
@@ -166,17 +195,20 @@ function PanelEditorContainer({
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDef={panelDef}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
{panelDefinition && (
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isLoading={isLoading || isFetching}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
@@ -210,6 +242,7 @@ function PanelEditorContainer({
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels share a fixed id segment, carrying kind + target section
|
||||
// in the query: `/panel/new?panelKind=signoz/ListPanel&layoutIndex=2`. The real
|
||||
// id is generated on save.
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
|
||||
/** Query string (incl. leading `?`) for the new-panel editor route. */
|
||||
export function newPanelSearch(
|
||||
pluginKind: PanelKind,
|
||||
layoutIndex?: number,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: pluginKind });
|
||||
if (layoutIndex !== undefined) {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PanelKind a `panel/new` route is creating, or null when the id isn't the
|
||||
* new-panel sentinel or the `panelKind` param is missing/unknown (stale link).
|
||||
*/
|
||||
export function parseNewPanelKind(
|
||||
panelId: string,
|
||||
search: string,
|
||||
): PanelKind | null {
|
||||
if (panelId !== NEW_PANEL_ID) {
|
||||
return null;
|
||||
}
|
||||
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
|
||||
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
if (raw === null || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,27 +1,39 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Clock, RotateCw } from '@signozhq/icons';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
import PanelMessage from '../PanelMessage/PanelMessage';
|
||||
|
||||
interface NoDataProps {
|
||||
/** Message to display. Defaults to "No data". */
|
||||
label?: string;
|
||||
/** Title override. Defaults to the time-range empty-state copy. */
|
||||
title?: string;
|
||||
/** Description override. Defaults to the "widen the range" hint. */
|
||||
description?: string;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared empty-state for panel renderers, shown when a query resolves but
|
||||
* returns nothing to plot. Centred in the panel body so every panel kind
|
||||
* surfaces the same "No data" affordance instead of each renderer (or its
|
||||
* underlying chart) inventing its own copy and casing.
|
||||
* Shared empty-state for panel renderers: wraps `PanelMessage` so every panel
|
||||
* kind surfaces the same "no data" affordance when a query returns nothing.
|
||||
*/
|
||||
function NoData({
|
||||
label = 'No data',
|
||||
title = 'No data in this time range',
|
||||
description = 'Nothing in the selected window. Try widening the range.',
|
||||
onRetry,
|
||||
'data-testid': testId = 'panel-no-data',
|
||||
}: NoDataProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.noData} data-testid={testId}>
|
||||
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
|
||||
</div>
|
||||
<PanelMessage
|
||||
icon={<Clock size={18} />}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
onRetry
|
||||
? { label: 'Retry', onClick: onRetry, icon: <RotateCw size={14} /> }
|
||||
: undefined
|
||||
}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Centred, vertically-stacked panel state (no query / no data / error). Fills
|
||||
// the panel body below the header and centres its content both axes.
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
|
||||
// rather than an actionable control.
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--l2-foreground);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.iconDanger {
|
||||
color: var(--bg-cherry-500);
|
||||
background: var(--bg-cherry-500-transparent, rgba(231, 64, 64, 0.12));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
max-width: 280px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './PanelMessage.module.scss';
|
||||
|
||||
export interface PanelMessageAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** Optional leading icon for the action button. */
|
||||
icon?: ReactElement;
|
||||
}
|
||||
|
||||
interface PanelMessageProps {
|
||||
/** Glyph shown above the title — sets the state's visual identity. */
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
/** Secondary line explaining the state / suggesting a next step. */
|
||||
description?: string;
|
||||
/** Optional call-to-action (e.g. Retry). Omitted → no button. */
|
||||
action?: PanelMessageAction;
|
||||
/** `danger` tints the icon for failure states; `neutral` for empty states. */
|
||||
tone?: 'neutral' | 'danger';
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared centred panel state (icon + title + optional description/action) so the
|
||||
* no-query / no-data / error states stay visually consistent across call sites.
|
||||
*/
|
||||
function PanelMessage({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
tone = 'neutral',
|
||||
'data-testid': testId,
|
||||
}: PanelMessageProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.message} data-testid={testId}>
|
||||
<div className={cx(styles.icon, { [styles.iconDanger]: tone === 'danger' })}>
|
||||
{icon}
|
||||
</div>
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={action.icon}
|
||||
onClick={action.onClick}
|
||||
className={styles.action}
|
||||
data-testid={testId ? `${testId}-action` : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelMessage;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import PanelMessage from '../PanelMessage';
|
||||
|
||||
describe('PanelMessage', () => {
|
||||
it('renders the icon, title and description', () => {
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={<svg data-testid="icon" />}
|
||||
title="Nothing to visualize yet"
|
||||
description="This panel has no query."
|
||||
data-testid="panel-state"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing to visualize yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('This panel has no query.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no action button when no action is provided', () => {
|
||||
render(
|
||||
<PanelMessage icon={null} title="No data" data-testid="panel-state" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('panel-state-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the action button and fires onClick when pressed', () => {
|
||||
const onClick = jest.fn();
|
||||
render(
|
||||
<PanelMessage
|
||||
icon={null}
|
||||
title="Couldn’t load panel data"
|
||||
action={{ label: 'Retry', onClick }}
|
||||
data-testid="panel-error"
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('panel-error-action');
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ function BarPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,9 +43,8 @@ function BarPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -138,7 +138,7 @@ function BarPanelRenderer({
|
||||
data-testid="bar-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Bar stacking lives in `visualization.stackedBarChart`, so it's a `visualization`
|
||||
// control, not `chartAppearance`. fillSpans is TimeSeries-only, so Bar omits it (V1 parity).
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, stacking: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, stacking: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
|
||||
@@ -26,6 +26,7 @@ function HistogramPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
@@ -34,9 +35,8 @@ function HistogramPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ function HistogramPanelRenderer({
|
||||
data-testid="histogram-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true },
|
||||
},
|
||||
{
|
||||
kind: 'legend',
|
||||
controls: { position: true },
|
||||
|
||||
@@ -26,14 +26,14 @@ import { useListRowInteraction } from './useListRowInteraction';
|
||||
|
||||
import styles from './ListPanel.module.scss';
|
||||
|
||||
// `body` flexes to fill the remaining table width (module-level so the resize
|
||||
// hook's memo dependency stays referentially stable across renders).
|
||||
// `body` flexes to fill remaining width; module-level to stay referentially stable for the resize hook's memo.
|
||||
const BODY_FLEX_COLUMNS = ['body'];
|
||||
|
||||
function ListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
pagination,
|
||||
}: PanelRendererProps<'signoz/ListPanel'>): JSX.Element {
|
||||
@@ -42,16 +42,14 @@ function ListPanelRenderer({
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { scrollY } = useMemo(() => computeTableLayout(height), [height]);
|
||||
|
||||
// The registry guarantees this Renderer only runs for `signoz/ListPanel`, so
|
||||
// the cast is a documented boundary narrowing.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesListPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesListPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
// Telemetry signal of the panel's first builder query — drives data flattening,
|
||||
// per-signal cell rendering, and the row-click behavior (log drawer vs trace
|
||||
// navigation). Cast at this boundary (the query carries the same string values).
|
||||
// Telemetry signal of the first builder query; drives flattening, cell rendering,
|
||||
// and row-click behavior. Cast is safe — the query carries the same string values.
|
||||
const signal = useMemo(
|
||||
() =>
|
||||
(getBuilderQueries(panel.spec.queries)[0]
|
||||
@@ -83,7 +81,7 @@ function ListPanelRenderer({
|
||||
[table, signal, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel; `body` flexes to fill width.
|
||||
// User-resizable columns, persisted per panel.
|
||||
const { columns: resizableColumns, components } = useResizableColumns({
|
||||
panelId,
|
||||
columns,
|
||||
@@ -92,8 +90,7 @@ function ListPanelRenderer({
|
||||
|
||||
const dataSource = useMemo(() => table?.rows ?? [], [table]);
|
||||
|
||||
// Header search filters the current page client-side (V1 parity); paging
|
||||
// across pages is server-side via `pagination`.
|
||||
// Header search filters the current page client-side (V1 parity); cross-page paging is server-side via `pagination`.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
@@ -115,8 +112,7 @@ function ListPanelRenderer({
|
||||
[spec.selectFields],
|
||||
);
|
||||
|
||||
// Show the footer whenever the panel pages server-side (no explicit query
|
||||
// limit), so the page-size picker is always reachable — V1 parity.
|
||||
// Show the footer whenever the panel pages server-side, so the page-size picker stays reachable (V1 parity).
|
||||
const showPager = !!pagination;
|
||||
|
||||
return (
|
||||
@@ -126,7 +122,7 @@ function ListPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -142,9 +138,7 @@ function ListPanelRenderer({
|
||||
components={components}
|
||||
dataSource={filteredDataSource}
|
||||
pagination={false}
|
||||
// Scroll the body vertically only — no `x: 'max-content'`, which
|
||||
// forced a content-width min and pushed columns off-screen;
|
||||
// `tableLayout="fixed"` fits them to the available width.
|
||||
// Vertical scroll only; `x: 'max-content'` forced a content-width min that pushed columns off-screen.
|
||||
scroll={{ y: scrollY }}
|
||||
onRow={onRow}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type DashboardtypesListPanelSpecDTO,
|
||||
type DashboardtypesPanelDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
@@ -10,16 +9,19 @@ import type {
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { fireEvent, render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import ListPanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesListPanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/ListPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/ListPanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/ListPanel'>;
|
||||
}
|
||||
|
||||
// V5 raw response: one result carrying flattened log rows.
|
||||
@@ -49,9 +51,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/ListPanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/ListPanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
displayName: 'List',
|
||||
Renderer,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
sections,
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -2,4 +2,7 @@ import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
// List columns are edited below the query builder, not in the config pane, so
|
||||
// only Context Links shows here.
|
||||
export const sections: SectionConfig[] = [{ kind: 'contextLinks' }];
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { switchPanelKind: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
|
||||
@@ -16,10 +16,10 @@ import ValueDisplay from './components/ValueDisplay/ValueDisplay';
|
||||
function NumberPanelRenderer({
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function NumberPanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{value === null ? (
|
||||
<NoData data-testid="number-panel-no-data" />
|
||||
<NoData data-testid="number-panel-no-data" onRetry={refetch} />
|
||||
) : (
|
||||
<ValueDisplay
|
||||
value={formattedValue}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'comparison' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -20,13 +20,13 @@ function PiePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec as DashboardtypesPieChartPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ function PiePanelRenderer({
|
||||
return (
|
||||
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
|
||||
{slices.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<Pie
|
||||
data={slices}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend.
|
||||
// Legend `colors` is omitted: the pie legend is always interactive swatches.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'legend', controls: { position: true } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -25,10 +25,10 @@ function TablePanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
searchTerm = '',
|
||||
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) and the
|
||||
// header stays pinned while the body scrolls.
|
||||
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(containerRef);
|
||||
const { pageSize, scrollY } = useMemo(
|
||||
@@ -36,12 +36,9 @@ function TablePanelRenderer({
|
||||
[height],
|
||||
);
|
||||
|
||||
// The registry guarantees this Renderer only runs when
|
||||
// `panel.spec.plugin.kind === 'signoz/TablePanel'`, so the cast is a
|
||||
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
|
||||
// produce a fresh object on each render.
|
||||
// `panel` is narrowed to this kind by PanelRendererProps, so no cast needed.
|
||||
const spec = useMemo<DashboardtypesTablePanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTablePanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -92,15 +89,13 @@ function TablePanelRenderer({
|
||||
[table],
|
||||
);
|
||||
|
||||
// Header search filters rows client-side (V1 parity). Falls back to the full
|
||||
// set when the term is empty, so non-searching tables pay nothing.
|
||||
// Header search filters rows client-side (V1 parity); empty term returns the full set, so non-searching tables pay nothing.
|
||||
const filteredDataSource = useMemo(
|
||||
() => filterTableRows(dataSource, searchTerm),
|
||||
[dataSource, searchTerm],
|
||||
);
|
||||
|
||||
// Keep pagination in range as the filtered set shrinks: a new term snaps back
|
||||
// to the first page so the user never lands on a now-empty page.
|
||||
// Snap back to page 1 on a new search term so the filtered set never lands on a now-empty page.
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [searchTerm]);
|
||||
|
||||
@@ -111,7 +106,7 @@ function TablePanelRenderer({
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!table || dataSource.length === 0 ? (
|
||||
<NoData />
|
||||
<NoData onRetry={refetch} />
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<Table
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesTablePanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,16 +6,19 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { BaseRendererProps } from '../../../types/rendererProps';
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import TablePanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTablePanelSpecDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
): PanelOfKind<'signoz/TablePanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TablePanel', spec } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
} as unknown as PanelOfKind<'signoz/TablePanel'>;
|
||||
}
|
||||
|
||||
// V5 scalar response: one joined result with a group column + an aggregation column.
|
||||
@@ -60,9 +62,9 @@ const emptyData: PanelQueryData = {
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<BaseRendererProps>,
|
||||
props: Partial<PanelRendererProps<'signoz/TablePanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
const baseProps: BaseRendererProps = {
|
||||
const baseProps: PanelRendererProps<'signoz/TablePanel'> = {
|
||||
panelId: 'panel-1',
|
||||
panel: panelWith({}),
|
||||
data: emptyData,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { SectionConfig } from '../../types/sections';
|
||||
// single column set). It exposes the per-panel time scope, formatting (decimals +
|
||||
// per-column units), per-column thresholds, and context links.
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { decimals: true, columnUnits: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'table' } },
|
||||
{ kind: 'contextLinks' },
|
||||
|
||||
@@ -32,6 +32,7 @@ function TimeSeriesPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
@@ -42,10 +43,8 @@ function TimeSeriesPanelRenderer({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// The registry guarantees the kind, so the cast is a boundary narrowing.
|
||||
// Memoized so the `?? {}` fallback doesn't produce a fresh object each render.
|
||||
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
|
||||
() => (panel.spec.plugin.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
@@ -140,7 +139,7 @@ function TimeSeriesPanelRenderer({
|
||||
data-testid="time-series-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{flatSeries.length === 0 && <NoData />}
|
||||
{flatSeries.length === 0 && <NoData onRetry={refetch} />}
|
||||
{flatSeries.length > 0 &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{ kind: 'visualization', controls: { timePreference: true, fillSpans: true } },
|
||||
{
|
||||
kind: 'visualization',
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'legend', controls: { position: true, colors: true } },
|
||||
|
||||
@@ -11,9 +11,7 @@ import type {
|
||||
} from './types/panelDefinition';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
@@ -27,12 +25,7 @@ export const PANELS: PanelRegistry = {
|
||||
export function getPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderablePanelDefinition | undefined {
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
// The registry is correlated by kind, so a string lookup yields a union over
|
||||
// every kind's exactly-typed definition. The renderer cannot be validated
|
||||
// against that union at the JSX boundary, so widen to the kind-agnostic
|
||||
// surface here — the single, intentional cast for the whole panel system.
|
||||
return PANELS[kind] as unknown as RenderablePanelDefinition | undefined;
|
||||
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
|
||||
// prop surface (a per-kind renderer can't be statically validated against the union).
|
||||
return PANELS[kind] as RenderablePanelDefinition | undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Which panel actions a kind supports. Required field, so registering a new
|
||||
@@ -35,7 +35,7 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
sections: SectionConfig[];
|
||||
supportedSignals: DataSource[];
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -22,37 +26,59 @@ export interface DashboardPreference {
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Kind-agnostic props every renderer receives. Kind-specific interaction props
|
||||
// are layered on per-kind by PanelRendererProps<K>.
|
||||
/** Kind-agnostic props every renderer receives; kind-specific interactions are layered on by PanelRendererProps<K>. */
|
||||
export interface BaseRendererProps {
|
||||
panelId: string;
|
||||
/**
|
||||
* The whole perses panel — renderers derive `spec` and `queries` from this.
|
||||
* Required: the render boundary only mounts a renderer once the panel and its
|
||||
* kind are resolved, so a renderer never sees an absent panel.
|
||||
*/
|
||||
/** The whole panel — renderers derive `spec` and `queries` from it. Required: the render boundary only mounts once panel + kind resolve. */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
/** Re-run the panel query; wired to the no-data Retry affordance. Optional so standalone call sites (e.g. the editor preview) can omit it. */
|
||||
refetch?: () => void;
|
||||
/** Gate for the drill-down right-click menu. Off by default in V2. */
|
||||
enableDrillDown?: boolean;
|
||||
/** Render context (dashboard widget vs. standalone vs. editor); see PanelMode. */
|
||||
panelMode: PanelMode;
|
||||
/** Dashboard-level preferences propagated to every panel; shell resolves, renderer consumes. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/**
|
||||
* Free-text filter from the header search box, applied client-side. Only
|
||||
* meaningful for kinds that declare `actions.search`; others ignore it.
|
||||
*/
|
||||
/** Free-text header filter, applied client-side. Only meaningful for kinds that declare `actions.search`. */
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles. Present only for raw/list panels; others ignore it. */
|
||||
pagination?: PanelPagination;
|
||||
}
|
||||
|
||||
// Renderer props for a specific kind: shared base plus that kind's interaction
|
||||
// surface. Indexing PanelInteractionMap forces it to cover every PanelKind; the
|
||||
// default K = PanelKind yields the widest surface (a union over all kinds).
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> =
|
||||
BaseRendererProps & PanelInteractionMap[K];
|
||||
// The single plugin variant for kind K, picked from the generated plugin union.
|
||||
// Distributes over the union, coercing each member's nominal kind-enum to its
|
||||
// string value (`${VK & string}`) to match K. K = PanelKind recovers the full union.
|
||||
type PluginOfKind<K extends PanelKind> =
|
||||
DashboardtypesPanelPluginDTO extends infer V
|
||||
? V extends { kind: infer VK }
|
||||
? `${VK & string}` extends K
|
||||
? V
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// The panel narrowed to kind K: the wire DTO with `plugin` (and `plugin.spec`)
|
||||
// fixed to K's single variant, so a renderer reads `panel.spec.plugin.spec` as
|
||||
// its own spec type with no cast.
|
||||
export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
|
||||
DashboardtypesPanelDTO,
|
||||
'spec'
|
||||
> & {
|
||||
spec: Omit<DashboardtypesPanelSpecDTO, 'plugin'> & {
|
||||
plugin: PluginOfKind<K>;
|
||||
};
|
||||
};
|
||||
|
||||
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
|
||||
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
|
||||
// and only the gestures it supports. The default K = PanelKind is the widest surface.
|
||||
export type PanelRendererProps<K extends PanelKind = PanelKind> = Omit<
|
||||
BaseRendererProps,
|
||||
'panel'
|
||||
> & {
|
||||
panel: PanelOfKind<K>;
|
||||
} & PanelInteractionMap[K];
|
||||
|
||||
@@ -89,8 +89,11 @@ export interface SectionControls {
|
||||
spanGaps?: boolean;
|
||||
};
|
||||
buckets: { count?: boolean; width?: boolean; mergeQueries?: boolean };
|
||||
// stacking → stackedBarChart (Bar); fillSpans → fill gaps with 0 (TimeSeries).
|
||||
// switchPanelKind → the visualization-type switcher (every kind, so you can switch
|
||||
// away from any panel); stacking → stackedBarChart (Bar); fillSpans → fill gaps with
|
||||
// 0 (TimeSeries).
|
||||
visualization: {
|
||||
switchPanelKind: boolean;
|
||||
timePreference?: boolean;
|
||||
stacking?: boolean;
|
||||
fillSpans?: boolean;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { sections as barSections } from '../../kinds/BarChartPanel/sections';
|
||||
import { sections as histogramSections } from '../../kinds/HistogramPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
import { sections as timeSeriesSections } from '../../kinds/TimeSeriesPanel/sections';
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
import { buildDefaultPluginSpec } from '../buildDefaultPluginSpec';
|
||||
|
||||
describe('buildDefaultPluginSpec', () => {
|
||||
it('seeds the TimeSeries dropdowns/segmented controls with their renderer defaults', () => {
|
||||
expect(buildDefaultPluginSpec(timeSeriesSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.solid,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
fillMode: DashboardtypesFillModeDTO.none,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits chartAppearance for a kind that does not declare it (Bar)', () => {
|
||||
expect(buildDefaultPluginSpec(barSections)).toStrictEqual({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
},
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds only the legend for Histogram (no visualization section)', () => {
|
||||
expect(buildDefaultPluginSpec(histogramSections)).toStrictEqual({
|
||||
legend: { position: DashboardtypesLegendPositionDTO.bottom },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty spec for a kind with no seeded controls (List)', () => {
|
||||
expect(buildDefaultPluginSpec(listSections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('does not seed controls that already show a clear default', () => {
|
||||
// `axes` and `formatting` stay unset — their empty state is the chart default.
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'axes', controls: { minMax: true, logScale: true } },
|
||||
{ kind: 'formatting', controls: { unit: true, decimals: true } },
|
||||
{ kind: 'thresholds', controls: { variant: 'label' } },
|
||||
{ kind: 'contextLinks' },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('only seeds the legend position when the kind exposes that control', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: 'legend', controls: { colors: true } },
|
||||
];
|
||||
expect(buildDefaultPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelQueryType } from '../getPanelQueryType';
|
||||
|
||||
function panelWithEnvelopes(envelopes: unknown[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'P' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: envelopes.length
|
||||
? [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/CompositeQuery', spec: { queries: envelopes } },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('getPanelQueryType', () => {
|
||||
it('returns undefined when the panel has no query', () => {
|
||||
expect(getPanelQueryType(panelWithEnvelopes([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports the builder mode for builder queries', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'builder_query', spec: { signal: 'traces', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('reports PromQL when a promql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'promql', spec: { query: 'up', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.PROM);
|
||||
});
|
||||
|
||||
it('reports ClickHouse when a clickhouse_sql envelope is present', () => {
|
||||
const panel = panelWithEnvelopes([
|
||||
{ type: 'clickhouse_sql', spec: { query: 'SELECT 1', name: 'A' } },
|
||||
]);
|
||||
expect(getPanelQueryType(panel)).toBe(EQueryType.CLICKHOUSE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SectionConfig, SectionSpecMap } from '../types/sections';
|
||||
|
||||
/**
|
||||
* Seeded plugin-spec slices, typed as canonical section slices so each value is
|
||||
* checked against its DTO. A partial cross-section, not any single kind's spec,
|
||||
* so the union cast stays localized to `createDefaultPanel`.
|
||||
*/
|
||||
export interface DefaultPluginSpec {
|
||||
visualization?: SectionSpecMap['visualization'];
|
||||
legend?: SectionSpecMap['legend'];
|
||||
chartAppearance?: SectionSpecMap['chartAppearance'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds per-kind config defaults derived from the kind's declared `sections` so the
|
||||
* config pane opens populated. Values equal the renderer fallbacks (display only).
|
||||
* Controls whose empty state already IS the default are left unset.
|
||||
*/
|
||||
export function buildDefaultPluginSpec(
|
||||
sections: SectionConfig[],
|
||||
): DefaultPluginSpec {
|
||||
const spec: DefaultPluginSpec = {};
|
||||
|
||||
sections.forEach((section) => {
|
||||
switch (section.kind) {
|
||||
case 'visualization':
|
||||
if (section.controls.timePreference) {
|
||||
spec.visualization = {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'legend':
|
||||
if (section.controls.position) {
|
||||
spec.legend = { position: DashboardtypesLegendPositionDTO.bottom };
|
||||
}
|
||||
break;
|
||||
case 'chartAppearance': {
|
||||
const chartAppearance: SectionSpecMap['chartAppearance'] = {};
|
||||
if (section.controls.lineStyle) {
|
||||
chartAppearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (section.controls.lineInterpolation) {
|
||||
chartAppearance.lineInterpolation =
|
||||
DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (section.controls.fillMode) {
|
||||
chartAppearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (Object.keys(chartAppearance).length > 0) {
|
||||
spec.chartAppearance = chartAppearance;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spec;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { deriveQueryType } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
/**
|
||||
* The authoring mode (builder / ClickHouse / PromQL) of a panel's query. Returns
|
||||
* `undefined` when the panel has no query yet so callers can hide query-type chrome
|
||||
* (e.g. the editor preview's "Plotted with" tag) rather than defaulting to builder.
|
||||
*/
|
||||
export function getPanelQueryType(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): EQueryType | undefined {
|
||||
const envelopes = toQueryEnvelopes(panel.spec.queries ?? []);
|
||||
if (envelopes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return deriveQueryType(envelopes);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user